From af4b65697af0a12490b9539949524da0a42dcb63 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 24 Oct 2026 20:41:14 -0400 Subject: [PATCH] fix(agent): COD-549 preserve spawned ID on readiness transport errors --- .changeset/tidy-workers-return.md | 5 +++++ src/cli-agent.ts | 34 +++++++++++++++++-------------- test/cli-agent.test.ts | 29 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 .changeset/tidy-workers-return.md diff --git a/.changeset/tidy-workers-return.md b/.changeset/tidy-workers-return.md new file mode 100644 index 00000000..812810a0 --- /dev/null +++ b/.changeset/tidy-workers-return.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Preserve the created session ID in agent spawn output when the readiness request fails with a transport error. diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 6a5fad47..ac96664b 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -555,21 +555,25 @@ export async function agentSpawn(deps: AgentDeps, options: SpawnOptions): Promis let dead = false; const mark = composerReadyMark(options.mode); if (options.ready && mark) { - const wait = await deps.request(deps.ctx, { - method: 'GET', - path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, - query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, - timeoutMs: options.timeoutMs + 10_000, - }); - // A failed readiness call (waiter cap, 400, network) is its own error, not "the - // composer never showed up": report the real reason instead of the trust-dialog hint. - if (!wait.json?.success) readinessError = describeFailure(wait); - else { - const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; - // A worker that died while we waited is exit 3 like every other wait, not a - // "composer not seen" timeout that sends the caller looking for a dialog. - dead = waitExitCode(result) === EXIT.dead; - ready = !dead && Boolean(result?.matched); + try { + const wait = await deps.request(deps.ctx, { + method: 'GET', + path: `/api/v1/sessions/${encodeURIComponent(sid)}/wait-output`, + query: { match: mark, from: 'buffer', timeout: options.timeoutMs }, + timeoutMs: options.timeoutMs + 10_000, + }); + // A failed readiness call (waiter cap, 400, network) is its own error, not "the + // composer never showed up": report the real reason instead of the trust-dialog hint. + if (!wait.json?.success) readinessError = describeFailure(wait); + else { + const result = (wait.json.data as { wait?: WaitResult } | undefined)?.wait; + // A worker that died while we waited is exit 3 like every other wait, not a + // "composer not seen" timeout that sends the caller looking for a dialog. + dead = waitExitCode(result) === EXIT.dead; + ready = !dead && Boolean(result?.matched); + } + } catch (error) { + readinessError = getErrorMessage(error); } } diff --git a/test/cli-agent.test.ts b/test/cli-agent.test.ts index c6331aad..60454991 100644 --- a/test/cli-agent.test.ts +++ b/test/cli-agent.test.ts @@ -548,6 +548,35 @@ describe('agent spawn', () => { expect(deps.calls).toHaveLength(1); expect(deps.err.join('')).toMatch(/SESSION_BUSY/); }); + + it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => { + const deps = fakeDeps((options) => { + if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' }); + throw new Error('readiness connection reset'); + }, json); + const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch( + () => undefined + ); + + if (json) { + expect(deps.out).toHaveLength(1); + expect(JSON.parse(deps.out[0])).toEqual({ + sessionId: OTHER, + caseName: 'c', + readinessError: 'readiness connection reset', + }); + expect(deps.err).toEqual([]); + } else { + expect(deps.out).toEqual([OTHER]); + expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset'); + expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/); + } + expect(exit).toBe(EXIT.error); + expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([ + { method: 'POST', path: '/api/v1/quick-start' }, + { method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` }, + ]); + }); }); describe('session id prefixes', () => {