From e4337d2b2bfe28a5fff72a30a045c4c5e55a64df Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 5 Jul 2026 01:43:37 +0300 Subject: [PATCH] fix(framework): fail the turn on a non-zero claude-code exit A non-zero exit was only treated as a failure when the agent produced no text. If it streamed some text and then crashed, the driver resolved a result turn and the loop could score the crash as production-grade. Fail on any non-zero exit, surfacing stderr or the partial text as context. Closes #231 --- .changeset/claude-code-nonzero-exit.md | 5 +++++ .../framework/src/driver/claude-code.test.ts | 21 +++++++++++++++++++ packages/framework/src/driver/claude-code.ts | 9 +++++--- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .changeset/claude-code-nonzero-exit.md diff --git a/.changeset/claude-code-nonzero-exit.md b/.changeset/claude-code-nonzero-exit.md new file mode 100644 index 0000000..20cb133 --- /dev/null +++ b/.changeset/claude-code-nonzero-exit.md @@ -0,0 +1,5 @@ +--- +'@gemstack/framework': patch +--- + +Fix the Claude Code driver treating a non-zero agent exit as success when the agent had already streamed some text. A crash mid-build now fails the turn (surfacing stderr or the partial text) instead of resolving as a result the loop can score production-grade. diff --git a/packages/framework/src/driver/claude-code.test.ts b/packages/framework/src/driver/claude-code.test.ts index 390d420..aa1ce16 100644 --- a/packages/framework/src/driver/claude-code.test.ts +++ b/packages/framework/src/driver/claude-code.test.ts @@ -89,6 +89,27 @@ test('runClaude rejects on a non-zero exit with no result text', async () => { ) }) +test('runClaude rejects on a non-zero exit even when the agent streamed text', async () => { + const events: DriverEvent[] = [] + const lines = [JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'started building' }] } })] + await assert.rejects( + () => + runClaude({ + bin: 'claude', + args: [], + cwd: '/ws', + env: {}, + prompt: 'x', + spawn: fakeSpawn(lines, 1), + emit: e => events.push(e), + signals: [], + }), + /exited \(1\): started building/, + ) + assert.equal(events.at(-1)!.type, 'error') + assert.ok(!events.some(e => e.type === 'result')) +}) + test('ClaudeCodeDriver builds correct CLI args (permission mode, system, model)', async () => { let captured: string[] = [] const spawn: SpawnLike = (_cmd, args) => { diff --git a/packages/framework/src/driver/claude-code.ts b/packages/framework/src/driver/claude-code.ts index 8bd624f..a2bcd0f 100644 --- a/packages/framework/src/driver/claude-code.ts +++ b/packages/framework/src/driver/claude-code.ts @@ -187,10 +187,13 @@ export function runClaude(opts: RunClaudeOptions): Promise { child.on('close', code => { const turn = parser.result() - if (code !== 0 && !turn.text) { - const detail = stderrChunks.join('').trim() || `exit code ${code ?? 'null'}` + // A non-zero exit is a failed turn even when the agent streamed some text + // first: the loop gates on the outcome, so a crash mid-build must not pass + // as a result. Surface stderr, else the partial text, as context. + if (code !== 0) { + const detail = stderrChunks.join('').trim() || turn.text.trim() || `exit code ${code ?? 'null'}` opts.emit({ type: 'error', message: detail }) - finish(() => rejectPromise(new Error(`[framework] claude-code exited: ${detail}`))) + finish(() => rejectPromise(new Error(`[framework] claude-code exited (${code ?? 'null'}): ${detail}`))) return } opts.emit({ type: 'result', text: turn.text, ...(turn.sessionId ? { sessionId: turn.sessionId } : {}) })