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
33 changes: 22 additions & 11 deletions packages/agent-backend/src/lib/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatAgentCliNotFoundRemediation,
} from "./agent-cli-not-found.js"
import {
collectChildStderrTail,
collectChildStdout,
collectChildStdoutAndStderr,
} from "./collect-child-stdout.js"
Expand All @@ -20,6 +21,7 @@ import {
AgentBackendTimeoutError,
} from "./errors.js"
import { killProcessTree } from "./kill-process-tree.js"
import { sanitizeAgentBackendStderrTail } from "./sanitize-exit-message.js"
import type { AgentBackendDescriptor, OnSessionId } from "./types.js"

/** Graceful terminate then force-kill bound for the Agent Turn process tree. */
Expand Down Expand Up @@ -143,8 +145,8 @@ const commandOptions = (input: {
env: input.env,
extendEnv: false as const,
stdin: input.stdin ?? ("ignore" as const),
// Turns always ignore stderr so a chatty CLI cannot fill an undrained pipe.
// Capture probes opt in via captureStderr on runCliCapture only.
// Piped streams must be drained inside the same raced collect as exitCode.
// Never leave a piped stream undrained (an undrained pipe can deadlock).
stderr: (input.captureStderr === true ? "pipe" : "ignore") as
| "pipe"
| "ignore",
Expand Down Expand Up @@ -270,7 +272,8 @@ export const runCliCapture = (

/**
* Run a CLI Agent Turn: stream stdout lines, observe early Session ID, fold
* ordered assistant text, require a Session ID on success.
* ordered assistant text, drain stderr into a bounded tail, require a
* Session ID on success.
*
* A startup-only inactivity bound fails the turn as soon as it is clear the CLI
* never started (no stdout output and no successful `observeStartup` within
Expand Down Expand Up @@ -300,8 +303,7 @@ export const runCliTurn = (
const command = ChildProcess.make(
input.binary,
[...input.args],
// Never pipe stderr for turns (undrained stderr can deadlock).
commandOptions({ ...input, captureStderr: false }),
commandOptions({ ...input, captureStderr: true }),
)

const result = yield* Effect.scoped(
Expand Down Expand Up @@ -443,12 +445,19 @@ export const runCliTurn = (
),
)

const [exitOutcome, output] = yield* Effect.all(
[handle.exitCode.pipe(Effect.result), collectOutput],
{ concurrency: 2 },
const [exitOutcome, output, stderrTail] = yield* Effect.all(
[
handle.exitCode.pipe(Effect.result),
collectOutput,
collectChildStderrTail(handle),
],
{ concurrency: 3 },
).pipe(
// raceFirst so the armed watchdog failure ends the turn immediately
// instead of waiting for the silent CLI to exit on its own.
// The stderr fold is inside this race so a piped stream is never
// left undrained, a finalize kill is not blocked waiting for it,
// and interrupting the race cannot leak the fold fiber.
Effect.raceFirst(startupWatchdog),
)

Expand All @@ -459,6 +468,7 @@ export const runCliTurn = (
finalized: output.finalized,
errorClassification: output.errorClassification,
errorMessage: output.errorMessage,
stderrTail,
}
}),
).pipe(
Expand All @@ -485,16 +495,17 @@ export const runCliTurn = (
const exitCode = Number(result.exitOutcome.success)
if (exitCode !== 0) {
const sessionId = result.sessionId ?? knownSessionId
const message =
result.errorMessage ??
sanitizeAgentBackendStderrTail(result.stderrTail)
return yield* AgentBackendExitError.new({
exitCode,
cwd: input.cwd,
...(sessionId !== undefined ? { sessionId } : {}),
...(result.errorClassification !== undefined
? { classification: result.errorClassification }
: {}),
...(result.errorMessage !== undefined
? { message: result.errorMessage }
: {}),
...(message !== undefined ? { message } : {}),
})
}
}
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-backend/src/lib/collect-child-stdout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,41 @@ export const collectChildStdout = (
}
})

/**
* Memory bound for an Agent Turn stderr fold. Only this many characters
* are retained; older output is dropped. Matches the Install Dependencies
* diagnostic tail so a chatty CLI cannot grow unbounded.
*/
const CLI_TURN_STDERR_TAIL_LIMIT = 4_000

const appendStderrTail = (
tail: string,
chunk: string,
limit = CLI_TURN_STDERR_TAIL_LIMIT,
): string => {
const combined = `${tail}${chunk}`
return combined.length <= limit
? combined
: `…${combined.slice(-(limit - 1))}`
}

/**
* Drain stderr to EOF, keeping only the most recent `limit` characters.
*
* Must be composed into the same concurrent collect as `exitCode` (and
* stdout) whenever stderr is piped — never leave a piped stream undrained.
*/
export const collectChildStderrTail = (
handle: ChildProcessHandle,
limit = CLI_TURN_STDERR_TAIL_LIMIT,
): Effect.Effect<string, PlatformError> =>
Stream.decodeText(handle.stderr).pipe(
Stream.runFold(
() => "",
(tail, chunk) => appendStderrTail(tail, chunk, limit),
),
)

/**
* Drain stdout and stderr concurrently to EOF, then read exit code.
*
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export class AgentBackendExitError extends Schema.TaggedErrorClass<AgentBackendE
* Best available human-readable reason. Optionality is transitional,
* not a design preference: a messageless exit error is the defect this
* field exists to remove. Flip to required in #1066 once every caller
* supplies a reason (blocked on stderr capture for the shared CLI turn
* runner).
* supplies a reason, including silent non-zero exits with no parsed
* reason and no stderr tail.
*/
message: Schema.optionalKey(Schema.String),
},
Expand Down
25 changes: 20 additions & 5 deletions packages/agent-backend/src/lib/sanitize-exit-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,24 @@ const TOKEN_SHAPED_RE =
/** Operator-visible exit reasons are persisted and rendered in a browser. */
const AGENT_BACKEND_EXIT_MESSAGE_MAX = 500

const cleanOperatorFacingText = (text: string): string =>
text.replace(ANSI_ESCAPE_RE, "").replace(TOKEN_SHAPED_RE, "[redacted]").trim()

export const sanitizeAgentBackendExitMessage = (text: string): string =>
text
.replace(ANSI_ESCAPE_RE, "")
.replace(TOKEN_SHAPED_RE, "[redacted]")
.trim()
.slice(0, AGENT_BACKEND_EXIT_MESSAGE_MAX)
cleanOperatorFacingText(text).slice(0, AGENT_BACKEND_EXIT_MESSAGE_MAX)

/**
* Redact the full captured tail, then keep the most recent bound so a
* token that would straddle a raw suffix cut is already gone.
*/
export const sanitizeAgentBackendStderrTail = (
text: string,
): string | undefined => {
const cleaned = cleanOperatorFacingText(text)
if (cleaned.length === 0) {
return undefined
}
return cleaned.length <= AGENT_BACKEND_EXIT_MESSAGE_MAX
? cleaned
: cleaned.slice(-AGENT_BACKEND_EXIT_MESSAGE_MAX)
}
Loading