fix: enforce unified agent startup invariants

This commit is contained in:
Tianyi Cui
2026-07-14 07:39:28 +08:00
parent 5a9d5f58a5
commit f02005c832
7 changed files with 55 additions and 10 deletions
@@ -19,6 +19,8 @@
* handler is in flight (it has streamed its chunk). A test
* polls for this file to cancel on a CONDITION rather than
* an arbitrary timeout (subprocess cold-start is variable).
* - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new`
* response to exercise startup rollback.
* - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat
* (MOCK_FLUSH_DELAY_MS, default 150) simulating the real
* acp-agent's EOF-driven quiesce+flush, then touches this
@@ -99,6 +101,7 @@ function makeAgent(conn: AgentSideConnection): Agent {
writeFileSync(NEWSESSION_GATE.ready, 'at-newSession')
while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10))
}
if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse
return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() }
},
authenticate(_params: AuthenticateRequest): Promise<void> {
@@ -195,6 +195,32 @@ describe('dsh-subagent-acp', () => {
}
})
it('reaps a child whose session/new response omits the session id', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
const flushed = join(tmp, 'flushed')
try {
await expect(startAcpRun(request(), {
command: process.execPath,
args: ['--import', tsxLoader, mockServer],
cwd: process.cwd(),
permission: 'reject',
env: {
MOCK_MISSING_SESSION_ID: '1',
MOCK_FLUSH_ON_EOF: flushed,
MOCK_FLUSH_DELAY_MS: '20',
TSX_TSCONFIG_PATH: repoTsconfig,
},
disposeEofGraceMs: 1000,
disposeGraceMs: 100,
})).rejects.toThrow('ACP child published without a session id')
// Startup rejects only after its private child reaches quiescence. The
// marker proves rollback closed stdin and allowed the child's EOF flush.
expect(existsSync(flushed)).toBe(true)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
// The child traps SIGTERM and keeps its event loop alive, so a graceful
// term alone would hang dispose forever. With a short grace, dispose must