diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3f856..916dec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.18.5] - 2026-08-28 + +### Fixed + +- **A turn no longer dies when its stored agent session has vanished.** The cage home volume can be recycled between turns (it is released when the task first parks), and `claude --resume` then exits with "No conversation found". The runner now drops the dead session and replays the turn once with the full context rebuilt into the prompt. +- **Agent stderr reaches the exit error.** Fatal reasons (a vanished session, an auth failure) go to stderr, not the JSONL stream; both the host and caged runs now tee stderr live AND carry its tail in the "exited with code N" message. + ## [0.18.4] - 2026-08-28 ### Added diff --git a/package.json b/package.json index 0fd63a8..584db4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesema-tools", - "version": "0.18.4", + "version": "0.18.5", "private": true, "type": "module", "workspaces": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index ab7b098..128a485 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "codesema", - "version": "0.18.4", + "version": "0.18.5", "description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.", "license": "MIT", "author": "Hasan TASKIN", diff --git a/packages/cli/src/agent.test.ts b/packages/cli/src/agent.test.ts index 6343416..e12f3f1 100644 --- a/packages/cli/src/agent.test.ts +++ b/packages/cli/src/agent.test.ts @@ -24,6 +24,7 @@ import { emitsOpencodeJson, hardenedReviewCommand, hostPolicyUnsafe, + isDeadSessionError, knownAgent, MAX_TIMER_MS, OPENCODE_REVIEW_CONFIG, @@ -2184,3 +2185,34 @@ describe('agentExitError', () => { expect(err.message).toBe('agent command exited with code 127') }) }) + +describe('agentExitError stderr tail', () => { + test('the stderr tail joins the stream detail in the message', () => { + const err = agentExitError( + 1, + '{"type":"result","subtype":"error_during_execution"}', + 'warming up\nNo conversation found with session ID: 0123\n', + ) + expect(err.message).toContain('error_during_execution') + expect(err.message).toContain('No conversation found with session ID: 0123') + }) + + test('stderr alone still carries the reason', () => { + const err = agentExitError(1, '', 'FATAL: something broke\n') + expect(err.message).toBe('agent command exited with code 1: FATAL: something broke') + }) +}) + +describe('isDeadSessionError', () => { + test('matches claude wording for a vanished --resume target, and nothing else', () => { + expect( + isDeadSessionError( + new Error( + 'agent command exited with code 1: error_during_execution; No conversation found with session ID: 0123', + ), + ), + ).toBe(true) + expect(isDeadSessionError(new Error('agent command exited with code 1'))).toBe(false) + expect(isDeadSessionError('No conversation found with session ID: 0123')).toBe(false) + }) +}) diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index 88831af..aa313b5 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -1230,11 +1230,22 @@ export function agentFailureDetail(out: string): string | null { return tail.length > 0 ? tail.slice(0, FAILURE_DETAIL_MAX) : null } +/** Stderr kept per run for the exit error; the stream stays teed to the real stderr. */ +export const AGENT_STDERR_TAIL_MAX = 8192 + +/** claude's own wording when a --resume target no longer exists (recycled cage home volume, cleared provider state). */ +export function isDeadSessionError(err: unknown): boolean { + return err instanceof Error && err.message.includes('No conversation found with session ID') +} + /** The exit-code message, carrying the agent's dying words when it left any. */ -export function agentExitError(code: number | null, out: string): Error { +export function agentExitError(code: number | null, out: string, errTail = ''): Error { const base = t('agent.exitCode', { code }) - const detail = agentFailureDetail(out) - return new Error(detail === null ? base : `${base}: ${detail}`) + const fromStream = agentFailureDetail(out) + const fromStderr = + errTail.trim().split('\n').slice(-3).join(' ').trim().slice(0, FAILURE_DETAIL_MAX) || null + const detail = [fromStream, fromStderr].filter((part) => part !== null).join('; ') + return new Error(detail.length === 0 ? base : `${base}: ${detail}`) } // --- clocks, spawning, and the shape of a kill ------------------------------ @@ -1481,7 +1492,7 @@ export function runAgent(opts: AgentRunOptions): Promise { const child = spawnFn(command, { shell: true, cwd: opts.cwd, - stdio: ['pipe', 'pipe', 'inherit'], + stdio: ['pipe', 'pipe', 'pipe'], detached, ...(opts.env !== undefined ? { env: opts.env } : {}), }) @@ -1490,6 +1501,13 @@ export function runAgent(opts: AgentRunOptions): Promise { // Registered BEFORE anything can close stdin: an agent that crashes closes // it early, and without this handler the EPIPE would kill the host process. stdin?.on('error', () => {}) + // Teed, not swallowed: fatal reasons (session gone, auth) go to stderr, + // not the JSONL stream — keep them visible live AND in the exit error. + let errTail = '' + child.stderr?.on('data', (chunk: Buffer) => { + process.stderr.write(chunk) + errTail = (errTail + chunk.toString('utf8')).slice(-AGENT_STDERR_TAIL_MAX) + }) let out = '' let capped = false @@ -1549,7 +1567,7 @@ export function runAgent(opts: AgentRunOptions): Promise { } else if (code === 0) { resolve(parser ? (parser.finalText() ?? out) : out) } else { - reject(agentExitError(code, out)) + reject(agentExitError(code, out, errTail)) } } diff --git a/packages/cli/src/task-isolation.ts b/packages/cli/src/task-isolation.ts index 28c164e..367ecc5 100644 --- a/packages/cli/src/task-isolation.ts +++ b/packages/cli/src/task-isolation.ts @@ -33,6 +33,7 @@ import { join } from 'node:path' import { AGENT_KILL_GRACE_MS, AGENT_SETTLE_GRACE_MS, + AGENT_STDERR_TAIL_MAX, AGENT_WATCHDOG_DEFAULTS, agentExitError, AgentWatchdogError, @@ -1794,10 +1795,19 @@ export const spawnContainer: ContainerSpawnFn = (opts) => new Promise((resolve, reject) => { const clock = opts.clock ?? systemClock const spawnProcessFn = opts.spawnProcessFn ?? spawn - const child = spawnProcessFn(opts.file, opts.args, { stdio: ['pipe', 'pipe', 'inherit'] }) + const child = spawnProcessFn(opts.file, opts.args, { stdio: ['pipe', 'pipe', 'pipe'] }) const stdin = child.stdin const stdout = child.stdout stdin?.on('error', () => {}) + // Teed, not swallowed: stderr still reaches the operator's journal live, + // AND its tail survives into the exit error — claude prints its fatal + // reason ("No conversation found with session ID …") there, not in the + // JSONL stream, and 'inherit' was throwing that reason away. + let errTail = '' + child.stderr?.on('data', (chunk: Buffer) => { + process.stderr.write(chunk) + errTail = (errTail + chunk.toString('utf8')).slice(-AGENT_STDERR_TAIL_MAX) + }) let out = '' let capped = false @@ -1837,7 +1847,7 @@ export const spawnContainer: ContainerSpawnFn = (opts) => } else if (code === 0) { resolve(out) } else { - reject(agentExitError(code, out)) + reject(agentExitError(code, out, errTail)) } } const killClient = (signal: NodeJS.Signals): void => { diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index f795a0d..4df1924 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -23,6 +23,7 @@ import { emitsClaudeStreamJson, emitsOpencodeJson, flagPresent, + isDeadSessionError, knownAgent, runAgent, type AgentHeartbeat, @@ -2455,7 +2456,10 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { const attempt: TurnAttempt = { cost: null, folded: false } const checksConfig = opts.getChecksConfig ? opts.getChecksConfig() : opts.checksConfig const taskCommand = commandForTask(record, opts.command) - return ( + // Re-read per attempt: the dead-session replay below clears + // agent_session_id, and both the prompt (full context vs message-only) + // and the session flag are derived from it inside runTaskTurn. + const execute = (): Promise => runTaskTurn({ cwd: record.worktree, task: record, @@ -2477,6 +2481,26 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { ...(checksConfig !== undefined ? { checksConfig } : {}), ...(opts.runContainerTurnFn ? { runContainerTurnFn: opts.runContainerTurnFn } : {}), }) + return ( + execute() + // A stored session can stop existing under the task (the cage home + // volume was recycled, the provider state was cleared): claude then + // dies at birth. One replay with the session dropped rebuilds the + // whole context into the prompt instead of failing the turn. + .catch((err: unknown) => { + if (controller.signal.aborted || !record.agent_session_id || !isDeadSessionError(err)) { + throw err + } + record.agent_session_id = null + persist(record) + emit(record.id, { + type: 'message', + data: { + text: 'the stored agent session no longer exists: replaying the turn with rebuilt context', + }, + }) + return execute() + }) .then((outcome) => { // The agent process is done: the task stops being interruptible as a // running turn here, even though the review that follows still holds