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.3] - 2026-08-28

### Fixed

- **The task egress proxy died at boot on rootful docker, silently taking every caged turn with it.** The generated squid.conf logged to /dev/stdout; squid drops from root to its `proxy` user after start, that user cannot reopen the container stdout pipe, and squid exits FATAL seconds after `run -d` reported success. With `--rm` erasing the evidence, each turn then ran with no route to the API and died minutes later as an opaque "agent command exited with code 1". The access log now goes to squid own log directory, and ensureEgressProxy probes the container right after start: a proxy that dies is reported immediately, with the crash output. Found on the first production runner, where no caged turn had ever actually reached the API.
- **A non-zero agent exit now carries the agent last words.** The final result frame of the claude stream (or the raw output tail) is appended to the exit-code error, on the host and in the cage, instead of being captured and thrown away.

## [0.18.2] - 2026-08-28

### Fixed
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.2",
"version": "0.18.3",
"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.2",
"version": "0.18.3",
"description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.",
"license": "MIT",
"author": "Hasan TASKIN",
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
AGENT_SETTLE_GRACE_MS,
AGENT_WATCHDOG_DEFAULTS,
agentEnv,
agentExitError,
agentFailureDetail,
agentReasonCode,
AgentWatchdogError,
boundedReadOnlyReviewCommand,
Expand Down Expand Up @@ -2135,3 +2137,50 @@ describe('createClaudeTaskParser cost', () => {
expect(tokens).toEqual([150, 430])
})
})

describe('agentFailureDetail', () => {
test('reads the last result frame of a claude stream', () => {
const out = [
'{"type":"system","subtype":"init"}',
'{"type":"result","subtype":"error_during_execution","result":"Request timed out"}',
].join('\n')
expect(agentFailureDetail(out)).toBe('error_during_execution: Request timed out')
})

test('a success frame with text keeps the text, without the subtype', () => {
const out = '{"type":"result","subtype":"success","result":"done"}'
expect(agentFailureDetail(out)).toBe('done')
})

test('falls back to the raw tail when nothing parses as a result frame', () => {
expect(agentFailureDetail('warming up\nRequest timed out')).toBe('warming up Request timed out')
})

test('empty output has no detail', () => {
expect(agentFailureDetail('')).toBeNull()
expect(agentFailureDetail(' \n ')).toBeNull()
})

test('detail is capped, never a page of stream dump', () => {
const detail = agentFailureDetail(
`{"type":"result","subtype":"success","result":"${'x'.repeat(2000)}"}`,
)
expect(detail).toHaveLength(400)
})
})

describe('agentExitError', () => {
test('carries the dying words next to the exit code', () => {
const err = agentExitError(
1,
'{"type":"result","subtype":"error_during_execution","result":"Request timed out"}',
)
expect(err.message).toContain('exited with code 1')
expect(err.message).toContain('Request timed out')
})

test('stays the bare exit message when the run said nothing', () => {
const err = agentExitError(127, '')
expect(err.message).toBe('agent command exited with code 127')
})
})
58 changes: 53 additions & 5 deletions packages/cli/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,11 +1169,10 @@ export function watchdogTickMs(budgets: WatchdogBudgets): number {
export class AgentWatchdogError extends Error {
/** Retryable in D2: what has to change is the RUN or its environment, not the work on the branch. */
readonly reasonCode: ReasonCode = 'inactivity_timeout'
constructor(
readonly watchdogCause: AgentWatchdogCause,
message: string,
) {
readonly watchdogCause: AgentWatchdogCause
constructor(watchdogCause: AgentWatchdogCause, message: string) {
super(message)
this.watchdogCause = watchdogCause
this.name = 'AgentWatchdogError'
}
}
Expand All @@ -1189,6 +1188,55 @@ export function watchdogMessage(cause: AgentWatchdogCause, elapsedMs: number): s
return cause === 'inactivity' ? t('agent.inactivity', { m }) : t('agent.toolBudget', { m })
}

const FAILURE_DETAIL_MAX = 400

/**
* What a non-zero agent exit actually said: the last result frame of a claude
* JSONL stream when there is one, the tail of raw output otherwise. Without
* this the run's dying words are captured and then thrown away, and every
* failure reads "exited with code 1".
*/
export function agentFailureDetail(out: string): string | null {
const lines = out.trimEnd().split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]?.trim()
if (!line || !line.startsWith('{')) {
continue
}
let frame: unknown
try {
frame = JSON.parse(line)
} catch {
continue
}
if (typeof frame !== 'object' || frame === null) {
continue
}
const f = frame as { type?: unknown; subtype?: unknown; result?: unknown; error?: unknown }
if (f.type !== 'result') {
continue
}
const text =
typeof f.result === 'string' && f.result.length > 0
? f.result
: typeof f.error === 'string'
? f.error
: ''
const subtype = typeof f.subtype === 'string' && f.subtype !== 'success' ? f.subtype : ''
const detail = [subtype, text].filter((part) => part.length > 0).join(': ')
return detail.length > 0 ? detail.slice(0, FAILURE_DETAIL_MAX) : null
}
const tail = lines.slice(-3).join(' ').trim()
return tail.length > 0 ? tail.slice(0, FAILURE_DETAIL_MAX) : null
}

/** The exit-code message, carrying the agent's dying words when it left any. */
export function agentExitError(code: number | null, out: string): Error {
const base = t('agent.exitCode', { code })
const detail = agentFailureDetail(out)
return new Error(detail === null ? base : `${base}: ${detail}`)
}

// --- clocks, spawning, and the shape of a kill ------------------------------

/** Time source and timers of a run; injected so no test ever waits out a budget. */
Expand Down Expand Up @@ -1501,7 +1549,7 @@ export function runAgent(opts: AgentRunOptions): Promise<string> {
} else if (code === 0) {
resolve(parser ? (parser.finalText() ?? out) : out)
} else {
reject(new Error(t('agent.exitCode', { code })))
reject(agentExitError(code, out))
}
}

Expand Down
7 changes: 3 additions & 4 deletions packages/cli/src/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,11 +422,10 @@ const INVALID_JSON_RETRY_NOTE =
'Your previous output was not a valid JSON review. Output ONLY the JSON object now: no prose, no code fences.'

export class AgentOutputError extends Error {
constructor(
message: string,
readonly raw: string,
) {
readonly raw: string
constructor(message: string, raw: string) {
super(message)
this.raw = raw
}
}

Expand Down
Loading
Loading