Merge commit '404501a41ccf2a3b638b1b737087948fe08d5c4c' into codex/product-subagent-presets

# Conflicts:
#	packages/subagent/subagent-claude-code/tests/real-product.spec.ts
This commit is contained in:
pku-xht
2026-08-10 12:59:50 +08:00
141 changed files with 1674 additions and 546 deletions

View File

@@ -49,6 +49,7 @@ interface ProfileLifecycleFixture {
ready: string
settled: string
disposed: string
interrupt: string
}
/**
@@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
const ready = join(home, 'ready')
const settled = join(home, 'settled')
const disposed = join(home, 'disposed')
const interrupt = join(home, 'interrupt')
const bundleDir = join(home, 'lifecycle-bundle')
mkdirSync(bundleDir, { recursive: true })
writeFileSync(join(bundleDir, 'plugin.mjs'), [
"import { writeFileSync } from 'node:fs'",
"import { existsSync, writeFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"export const name = 'profile-lifecycle-fixture'",
'export function apply(ctx, config = {}) {',
' let active = true',
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
' const heartbeat = setInterval(() => {}, 1000)',
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
' let interrupted = false',
' const heartbeat = setInterval(() => {',
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
' interrupted = true',
" process.emit('SIGTERM')",
' }, 20)',
' // Echo the mounted generation so the hot-reload e2e can assert both an',
' // applied override and its removal reverting to this bundle default.',
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
@@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
}
return { home, ready, settled, disposed }
return { home, ready, settled, disposed, interrupt }
}
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
@@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
RAW_READY_FILE: fixture.ready,
RAW_SETTLED_FILE: fixture.settled,
RAW_DISPOSED_FILE: fixture.disposed,
RAW_INTERRUPT_FILE: fixture.interrupt,
},
})
}
function requestProfileShutdown(
child: ReturnType<typeof startProfileLifecycle>,
fixture: ProfileLifecycleFixture,
): void {
if (process.platform === 'win32') {
writeFileSync(fixture.interrupt, 'interrupt')
return
}
child.kill('SIGTERM')
}
function createEnvironmentProbeProfile(home: string, project: string): void {
const pluginFile = join(project, 'environment-probe.mjs')
writeFileSync(pluginFile, [
@@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
" if (chunk.type === 'text-delta') text += chunk.text",
' }',
' process.stdout.write(`${text}\\n`)',
" process.kill(process.pid, 'SIGTERM')",
" if (process.platform === 'win32') process.emit('SIGTERM')",
" else process.kill(process.pid, 'SIGTERM')",
' })',
'}',
'',
@@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
const child = startProfileLifecycle(fixture)
try {
await waitForFile(fixture.ready)
child.kill('SIGTERM')
requestProfileShutdown(child, fixture)
const result = await child
expect(result.exitCode).toBe(0)
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
expect(result.signal).toBeUndefined()
expect(existsSync(fixture.disposed)).toBe(true)
} finally {
@@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
].join('\n'))
await waitForFile(fixture.ready)
expect(readFileSync(configFile, 'utf8')).toBe('home')
child.kill('SIGTERM')
requestProfileShutdown(child, fixture)
const result = await child
expect(result.exitCode).toBe(0)
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
expect(result.signal).toBeUndefined()
expect(existsSync(fixture.disposed)).toBe(true)
} finally {

View File

@@ -20,35 +20,50 @@ afterEach(() => {
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
const resolvedComplete = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
expect(resolvedComplete).toHaveBeenCalledOnce()
expect(resolvedComplete).toHaveBeenCalledWith(0)
expect(resolvedExit).not.toHaveBeenCalled()
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
const rejectedComplete = vi.fn()
const rejected = createProcessShutdown(
() => Promise.reject(new Error('dispose failed')),
rejectedExit,
rejectedComplete,
)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
expect(rejectedComplete).not.toHaveBeenCalled()
})
it('uses process.exit as the default process boundary', async () => {
it('uses process.exitCode for default normal completion', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const originalExitCode = process.exitCode
process.exitCode = undefined
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
try {
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
expect(process.exitCode).toBe(7)
expect(exit).not.toHaveBeenCalled()
} finally {
process.exitCode = originalExitCode
}
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('forces exit after disposal started by a signal', async () => {
const disposal = deferred()
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
shutdown.interrupt(143)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(143)
expect(complete).not.toHaveBeenCalled()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
shutdown.interrupt(143)
await Promise.resolve()
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
disposal.resolve()
await first
expect(complete).toHaveBeenCalledOnce()
expect(complete).toHaveBeenCalledWith(0)
expect(exit).not.toHaveBeenCalled()
})
it('lets a signal force exit while natural completion drains remaining handles', async () => {
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
await shutdown.shutdown(0)
shutdown.interrupt(130)
expect(complete).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
expect(exit).toHaveBeenCalledWith(130)
})
})