Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codesema-tools",
"version": "0.18.4",
"version": "0.18.5",
"private": true,
"type": "module",
"workspaces": [
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
emitsOpencodeJson,
hardenedReviewCommand,
hostPolicyUnsafe,
isDeadSessionError,
knownAgent,
MAX_TIMER_MS,
OPENCODE_REVIEW_CONFIG,
Expand Down Expand Up @@ -461,7 +462,7 @@
})

describe('createOpencodeTaskParser', () => {
const line = (event: unknown) => `${JSON.stringify(event)}\n`

Check warning on line 465 in packages/cli/src/agent.test.ts

View workflow job for this annotation

GitHub Actions / quality

unicorn(consistent-function-scoping)

Function `line` does not capture any variables from its parent scope

test('decoded JSON is activity, first sessionID fires onInit once', () => {
let beats = 0
Expand Down Expand Up @@ -610,7 +611,7 @@
})

describe('createClaudeStreamParser', () => {
const delta = (text: string) =>

Check warning on line 614 in packages/cli/src/agent.test.ts

View workflow job for this annotation

GitHub Actions / quality

unicorn(consistent-function-scoping)

Function `delta` does not capture any variables from its parent scope
`${JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text } } })}\n`

test('text_delta accumulated and onText called', () => {
Expand Down Expand Up @@ -658,7 +659,7 @@
})

describe('createClaudeTaskParser', () => {
const line = (event: unknown) => `${JSON.stringify(event)}\n`

Check warning on line 662 in packages/cli/src/agent.test.ts

View workflow job for this annotation

GitHub Actions / quality

unicorn(consistent-function-scoping)

Function `line` does not capture any variables from its parent scope

test('captures the session id from the system init event', () => {
let sessionId = ''
Expand Down Expand Up @@ -2184,3 +2185,34 @@
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)
})
})
28 changes: 23 additions & 5 deletions packages/cli/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------
Expand Down Expand Up @@ -1481,7 +1492,7 @@ export function runAgent(opts: AgentRunOptions): Promise<string> {
const child = spawnFn(command, {
shell: true,
cwd: opts.cwd,
stdio: ['pipe', 'pipe', 'inherit'],
stdio: ['pipe', 'pipe', 'pipe'],
detached,
...(opts.env !== undefined ? { env: opts.env } : {}),
})
Expand All @@ -1490,6 +1501,13 @@ export function runAgent(opts: AgentRunOptions): Promise<string> {
// 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
Expand Down Expand Up @@ -1549,7 +1567,7 @@ export function runAgent(opts: AgentRunOptions): Promise<string> {
} else if (code === 0) {
resolve(parser ? (parser.finalText() ?? out) : out)
} else {
reject(agentExitError(code, out))
reject(agentExitError(code, out, errTail))
}
}

Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/task-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/task-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
emitsClaudeStreamJson,
emitsOpencodeJson,
flagPresent,
isDeadSessionError,
knownAgent,
runAgent,
type AgentHeartbeat,
Expand Down Expand Up @@ -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<TaskTurnOutcome> =>
runTaskTurn({
cwd: record.worktree,
task: record,
Expand All @@ -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
Expand Down
Loading