Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- **Headless execution measurement inputs** — NDJSON events now carry CLI release identity and correlated retry lifecycle events with bounded reason and outcome categories. Existing retry behavior is unchanged. (#110)
- **GPT-5.6 Codex models** — Added OpenAI's Sol, Terra, and Luna models with API and subscription-backed reasoning effort variants, including the Codex-only `ultra` alias for Sol and Terra.
- **GLM-5.3 model support** — Added the latest Z.AI Coding Plan model with its 1M-token context window and native `low`, `high`, and `max` reasoning efforts.

Expand Down
65 changes: 64 additions & 1 deletion EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ When the parsed `aictrl run --format json` handler starts, the CLI emits newline
{
"type": "<event_type>",
"timestamp": 1741500000000,
"schemaVersion": "1",
"cliVersion": "0.4.3",
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7",
"sessionID": "session_01abc..."
}
```

`invocationID` is present on every event from `run --format json`. `sessionID` is present only after a real session has been created; invocation events never fabricate one.
`schemaVersion`, `cliVersion`, and `invocationID` are present on every event from `run --format json`. `cliVersion` identifies the emitting CLI release and is independent of the event schema version. `sessionID` is present only after a real session has been created; invocation events never fabricate one.

The schema is versioned via `invocation_start.schemaVersion` and `session_start.schemaVersion`. This document describes **schema version `"1"`**. Consumers should pin to this version and treat unknown fields as forward-compatible additions.

Expand All @@ -28,6 +30,7 @@ Emitted once, before piped stdin is read and before run validation or bootstrap
"type": "invocation_start",
"timestamp": 1741500000000,
"schemaVersion": "1",
"cliVersion": "0.4.3",
"invocationID": "7d142250-8bdc-43df-99af-efa252db62a7"
}
```
Expand Down Expand Up @@ -174,6 +177,66 @@ Emitted immediately before `session_complete` when the session terminates abnorm
- `code` (string, optional) — provider HTTP status code, error code, or conventional signal-derived exit code (`130` for `SIGINT`, `143` for `SIGTERM`) when available.
- `message` (string, **required**) — human-readable error message.

### `retry_scheduled`

Emitted when the primary session schedules an existing automatic retry. This event observes the retry policy; it does not add or change retry behavior.

```json
{
"type": "retry_scheduled",
"retryID": "96766fef-4f5c-47bb-a292-8fd43761ac3a",
"messageID": "msg_01abc...",
"providerID": "google",
"modelID": "gemini-2.5-flash",
"attempt": 1,
"reason": "rate_limit",
"delayMs": 2000
}
```

- `retryID` (string, **required**) — opaque identity shared with the matching `retry_complete` event.
- `messageID` (string or null, **required**) — assistant message that owns the retry. It is null only when attached to an older server that did not supply the additive correlation fields.
- `providerID` / `modelID` (string or null, **required**) — resolved provider and model when the retry source supplied them.
- `attempt` (number, **required**) — one-based retry ordinal for the assistant message.
- `reason` (string, **required**) — bounded category: `rate_limit`, `timeout`, `network`, `provider`, or `unknown`. Treat this as an open set. Free-text provider errors are not emitted as metric dimensions.
- `delayMs` (number, **required**) — scheduled backoff. For an older attached server this is the non-negative time remaining when the event is observed.

### `retry_complete`

Emitted when the scheduled retry is resolved by a terminal assistant message, another scheduled retry, a session error, cancellation, or an idle session boundary.

```json
{
"type": "retry_complete",
"retryID": "96766fef-4f5c-47bb-a292-8fd43761ac3a",
"messageID": "msg_01abc...",
"providerID": "google",
"modelID": "gemini-2.5-flash",
"attempt": 1,
"reason": "rate_limit",
"delayMs": 2000,
"outcome": "recovered"
}
```

Identity and dimension fields match `retry_scheduled`. `outcome` is `recovered`, `failed`, `aborted`, or `unknown`. A retry superseded by another retry is `failed`; a successful terminal assistant message is `recovered`; cancellation is `aborted`; an idle boundary without an observable terminal message is `unknown`. If the event stream ends before `retry_complete`, the attempt is censored and must remain unknown.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Doc: superseded retry not always "failed" in code.

Suggested change
Identity and dimension fields match `retry_scheduled`. `outcome` is `recovered`, `failed`, `aborted`, or `unknown`. A retry superseded by another retry is `failed`; a successful terminal assistant message is `recovered`; cancellation is `aborted`; an idle boundary without an observable terminal message is `unknown`. If the event stream ends before `retry_complete`, the attempt is censored and must remain unknown.
Qualify the EVENTS.md sentence to match the code: "A retry superseded by another retry for the same message is `failed`; supersession by a retry for a different message resolves via the prior message's observed terminal outcome." Alternatively make the code resolve unconditionally as "failed" when superseded.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, EVENTS.md:222):

Problem: Doc: superseded retry not always "failed" in code
Detail: EVENTS.md states unconditionally "A retry superseded by another retry is `failed`", but scheduleRetry in run.ts resolves the superseded retry as "failed" only when both retries carry the same non-null messageID; otherwise it falls back to retryOutcome(current), which can emit "recovered", "aborted", or "unknown". A pending retry for one assistant message superseded by a retry for a different message (or by an uncorrelated older-server retry) is therefore not necessarily "failed" — the PR's own test ("keeps retry outcomes scoped to their owning message across tool turns") relies on the cross-message behavior. Either the doc or the implementation is the contract; consumers following the doc will mis-aggregate the retry-recovery-rate metric.
Suggested fix: Qualify the EVENTS.md sentence to match the code: "A retry superseded by another retry for the same message is `failed`; supersession by a retry for a different message resolves via the prior message's observed terminal outcome." Alternatively make the code resolve unconditionally as "failed" when superseded.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

EVENTS.md states unconditionally "A retry superseded by another retry is failed", but scheduleRetry in run.ts resolves the superseded retry as "failed" only when both retries carry the same non-null messageID; otherwise it falls back to retryOutcome(current), which can emit "recovered", "aborted", or "unknown". A pending retry for one assistant message superseded by a retry for a different message (or by an uncorrelated older-server retry) is therefore not necessarily "failed" — the PR's own test ("keeps retry outcomes scoped to their owning message across tool turns") relies on the cross-message behavior. Either the doc or the implementation is the contract; consumers following the doc will mis-aggregate the retry-recovery-rate metric.

Identity and dimension fields match `retry_scheduled`. `outcome` is `recovered`, `failed`, `aborted`, or `unknown`. A retry superseded by another retry is `failed`; a successful terminal assistant message is `recovered`; cancellation is `aborted`; an idle boundary without an observable terminal message is `unknown`. If the event stream ends before `retry_complete`, the attempt is censored and must remain unknown.


## Measurement contract

The NDJSON stream supplies measurement inputs; it does not calculate product-specific results. Aggregate top-level invocations by `invocationID`, and use `sessionID` and `messageID` only for correlation. Do not count child sessions as new invocations.

| Metric | Numerator | Denominator | Unknown / censored handling |
|---|---|---|---|
| Invocation outcome rate | `invocation_complete` grouped by `status` | distinct `invocation_start.invocationID` | A start without a complete event is unknown, never success |
| Provider-turn error rate | primary `message_complete.status = error` | all primary `message_complete` events | A started invocation without a terminal primary message is unknown |
| Contradiction count | completed invocation whose primary message or session is terminally erroneous | completed invocations | Keep as a data-quality count; the terminal error fix is tracked separately |
| Diagnostic coverage | error turns with the separately defined provider diagnostic | provider-error turns | Raw provider reason availability and redaction are defined separately; do not infer them from `reason` |
| Retry recovery rate | `retry_complete.outcome = recovered` | `retry_complete` with outcome `recovered` or `failed` | Exclude `aborted`, `unknown`, and scheduled retries without a completion event |

Sum `retry_scheduled.delayMs` once per distinct `retryID` for additional scheduled backoff. Join usage and cost from the owning `message_complete.messageID`; `retry_complete` is a lifecycle correlation event and carries no duplicate usage or cost. Missing usage or cost remains unknown rather than zero.

Safe metric dimensions are `cliVersion`, `providerID`, `modelID`, and bounded `reason`. `invocationID`, `sessionID`, `messageID`, and `retryID` are high-cardinality correlation attributes. Never use free-text errors as metric labels.

## Message Events

### `message_complete`
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/cli/cmd/run.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export function classifySessionError(err: unknown): ClassifiedSessionError {
if (status === 429) return { reason: "rate_limit", code: "429", message }
if (status === 401 || status === 403) return { reason: "auth", code: String(status), message }
if (name === "ProviderAuthError") return { reason: "auth", code: status ? String(status) : undefined, message }
if (name === "MessageAbortedError") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New "interrupted" session_error reason undocumented.

Suggested change
if (name === "MessageAbortedError") {
Add "interrupted" to the session_error reason list in EVENTS.md (noting it is produced by MessageAbortedError/cancellation) in the same PR that introduces the emitted value.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/cli/cmd/run.errors.ts:27-29):

Problem: New "interrupted" session_error reason undocumented
Detail: This PR adds reason value "interrupted" to classifySessionError, and the new integration test (run-retry-telemetry.test.ts) asserts it is emitted as session_error.reason === "interrupted" on the NDJSON stream, yet EVENTS.md is not updated anywhere in this diff — "interrupted" appears nowhere in the document. EVENTS.md is the consumer contract this PR extends; downstream consumers switching on the documented bounded session_error reason set will observe an undocumented value.
Suggested fix: Add "interrupted" to the session_error reason list in EVENTS.md (noting it is produced by MessageAbortedError/cancellation) in the same PR that introduces the emitted value.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

This PR adds reason value "interrupted" to classifySessionError, and the new integration test (run-retry-telemetry.test.ts) asserts it is emitted as session_error.reason === "interrupted" on the NDJSON stream, yet EVENTS.md is not updated anywhere in this diff — "interrupted" appears nowhere in the document. EVENTS.md is the consumer contract this PR extends; downstream consumers switching on the documented bounded session_error reason set will observe an undocumented value.

  if (name === "MessageAbortedError") {\n    return { reason: "interrupted", code: status ? String(status) : undefined, message }\n  }

return { reason: "interrupted", code: status ? String(status) : undefined, message }
}
if (name === "AbortError" || /timeout/i.test(message)) {
return { reason: "timeout", code: status ? String(status) : undefined, message }
}
Expand All @@ -50,7 +53,8 @@ function extractMessage(err: unknown): string {
function extractStatus(err: unknown): number | undefined {
if (err && typeof err === "object") {
const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown }; data?: unknown }
const data = e.data && typeof e.data === "object" ? (e.data as { status?: unknown; statusCode?: unknown }) : undefined
const data =
e.data && typeof e.data === "object" ? (e.data as { status?: unknown; statusCode?: unknown }) : undefined
const raw = e.status ?? e.statusCode ?? e.response?.status ?? data?.status ?? data?.statusCode
if (typeof raw === "number") return raw
if (typeof raw === "string" && /^\d+$/.test(raw)) return Number(raw)
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/cmd/run.invocation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Stdout } from "../stdout"
import { SCHEMA_VERSION } from "./run.errors"
import { Installation } from "../../installation"

export type RunInvocationPhase = "validation" | "stdin" | "bootstrap" | "session"

Expand All @@ -19,6 +20,7 @@ export function createRunInvocation(enabled: boolean) {
type,
timestamp: Date.now(),
schemaVersion: SCHEMA_VERSION,
cliVersion: Installation.VERSION,
invocationID: id,
...data,
}),
Expand Down
110 changes: 100 additions & 10 deletions packages/cli/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { Shutdown } from "../shutdown"
import { Stdout } from "../stdout"
import { attempt, signals, type Signals } from "../signals"
import { createRunInvocation } from "./run.invocation"
import { Installation } from "../../installation"

type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
Expand Down Expand Up @@ -527,6 +528,7 @@ export const RunCommand = cmd({
type,
timestamp: Date.now(),
schemaVersion: SCHEMA_VERSION,
cliVersion: Installation.VERSION,
invocationID: invocation.id,
sessionID,
...data,
Expand All @@ -542,6 +544,70 @@ export const RunCommand = cmd({
const childSessions = new Set<string>()
const emitted = new Set<string>()
const seqBySession = new Map<string, number>()
type Retry = {
retryID: string
messageID: string | null
providerID: string | null
modelID: string | null
attempt: number
reason: string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Retry.reason typed string; status shape re-declared.

Suggested change
reason: string
Type `reason` as SessionRetry.Reason and have scheduleRetry accept the retry variant inferred from the status.ts zod schema (or SessionRetry.Reason-typed fields) instead of a hand-copied literal shape.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/cli/cmd/run.ts:553-555):

Problem: Retry.reason typed string; status shape re-declared
Detail: The bounded Reason union now lives in three mirrors (SessionRetry.Reason in retry.ts, the zod enum in status.ts, the SDK union in types.gen.ts), but run.ts types the stored/emitted Retry.reason as plain `string` and re-declares the entire session.status retry shape field-by-field in scheduleRetry's parameter instead of reusing the zod-inferred SessionStatus type. Any future field addition to status.ts must be mirrored by hand in run.ts or it silently drops out of telemetry, and the string typing discards the bounded-category guarantee EVENTS.md documents for `reason`.
Suggested fix: Type `reason` as SessionRetry.Reason and have scheduleRetry accept the retry variant inferred from the status.ts zod schema (or SessionRetry.Reason-typed fields) instead of a hand-copied literal shape.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The bounded Reason union now lives in three mirrors (SessionRetry.Reason in retry.ts, the zod enum in status.ts, the SDK union in types.gen.ts), but run.ts types the stored/emitted Retry.reason as plain string and re-declares the entire session.status retry shape field-by-field in scheduleRetry's parameter instead of reusing the zod-inferred SessionStatus type. Any future field addition to status.ts must be mirrored by hand in run.ts or it silently drops out of telemetry, and the string typing discards the bounded-category guarantee EVENTS.md documents for reason.

      type Retry = {\n        retryID: string\n        messageID: string | null\n        providerID: string | null\n        modelID: string | null\n        attempt: number\n        reason: string\n        delayMs: number\n      }

delayMs: number
}
const retries = new Map<string, Retry>()
const outcomes = new Map<string, { status: "completed" | "error" | "aborted"; finish: string | undefined }>()

function retryOutcome(retry: Retry) {
if (!retry.messageID) return "unknown" as const
const outcome = outcomes.get(retry.messageID)
if (!outcome) return "unknown" as const
if (outcome.status === "aborted") return "aborted" as const
if (outcome.status === "error" || outcome.finish === "error" || outcome.finish === "content-filter") {
return "failed" as const
}
return "recovered" as const
}

function resolveRetry(sid: string, outcome: "recovered" | "failed" | "aborted" | "unknown") {
const retry = retries.get(sid)
if (!retry) return
retries.delete(sid)
emit("retry_complete", { ...retry, outcome })
}

function scheduleRetry(
sid: string,
status: {
attempt: number
next: number
retryID?: string
messageID?: string
providerID?: string
modelID?: string
reason?: string
delayMs?: number
},
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Legacy retry statuses cannot dedupe on replay.

Suggested change
) {
For legacy statuses (no retryID), also dedupe on a stable key such as (attempt, next) or suppress re-scheduling while an unresolved legacy retry with the same attempt number exists.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/cli/cmd/run.ts:589):

Problem: Legacy retry statuses cannot dedupe on replay
Detail: scheduleRetry dedupes repeated retry statuses by comparing current.retryID === status.retryID. For an older server without correlation fields, the stored retryID is a locally generated UUID while status.retryID is undefined, so the comparison can never be true. Any redelivery of the same legacy retry status (SSE reconnect/replay or status re-broadcast) emits a fresh retry_scheduled plus a supersession retry_complete pair with a new retryID, double-counting retries and scheduled backoff in the documented per-retryID metrics. Correlated retries dedupe correctly; only legacy servers are affected. Repro: Given an older server (retry status without retryID) whose SSE stream reconnects with event replay, When the same retry status is delivered twice, Then two retry_scheduled/retry_complete pairs are emitted with distinct generated retryIDs.
Suggested fix: For legacy statuses (no retryID), also dedupe on a stable key such as (attempt, next) or suppress re-scheduling while an unresolved legacy retry with the same attempt number exists.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

scheduleRetry dedupes repeated retry statuses by comparing current.retryID === status.retryID. For an older server without correlation fields, the stored retryID is a locally generated UUID while status.retryID is undefined, so the comparison can never be true. Any redelivery of the same legacy retry status (SSE reconnect/replay or status re-broadcast) emits a fresh retry_scheduled plus a supersession retry_complete pair with a new retryID, double-counting retries and scheduled backoff in the documented per-retryID metrics. Correlated retries dedupe correctly; only legacy servers are affected. Repro: Given an older server (retry status without retryID) whose SSE stream reconnects with event replay, When the same retry status is delivered twice, Then two retry_scheduled/retry_complete pairs are emitted with distinct generated retryIDs.

        const current = retries.get(sid)\n        if (current && current.retryID === status.retryID) return\n        if (current) {\n          resolveRetry(\n            sid,\n            current.messageID && status.messageID === current.messageID ? "failed" : retryOutcome(current),\n          )\n        }

const current = retries.get(sid)
if (current && current.retryID === status.retryID) return
if (current) {
resolveRetry(
sid,
current.messageID && status.messageID === current.messageID ? "failed" : retryOutcome(current),
)
}
const retry = {
retryID: status.retryID ?? crypto.randomUUID(),
messageID: status.messageID ?? null,
providerID: status.providerID ?? null,
modelID: status.modelID ?? null,
attempt: status.attempt,
reason: status.reason ?? "unknown",
delayMs: status.delayMs ?? Math.max(0, status.next - Date.now()),
}
retries.set(sid, retry)
emit("retry_scheduled", retry)
}

function nextSeq(sid: string): number {
const n = (seqBySession.get(sid) ?? 0) + 1
seqBySession.set(sid, n)
Expand Down Expand Up @@ -569,7 +635,17 @@ export const RunCommand = cmd({
if (event.type === "message.updated" && event.properties.info.role === "assistant") {
const info = event.properties.info
if (args.format === "json") {
if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id)) {
if (info.sessionID === sessionID) {
const retry = retries.get(info.sessionID)
if (retry?.messageID && retry.messageID !== info.id) {
resolveRetry(info.sessionID, retryOutcome(retry))
}
}
if (info.sessionID === sessionID && info.time.completed !== undefined) {
const status =
info.error?.name === "MessageAbortedError" ? "aborted" : info.error ? "error" : "completed"
outcomes.set(info.id, { status, finish: info.finish })
if (emitted.has(info.id)) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 continue skips loop tail for duplicate completions.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/cli/cmd/run.ts:648):

Problem: continue skips loop tail for duplicate completions
Detail: The original code guarded only the emission with `&& !emitted.has(info.id)`, so duplicate completed updates still fell through to the rest of the event-loop body. The new `if (emitted.has(info.id)) continue` aborts processing of the event entirely, so any statements after the assistant json block in the message.updated loop body (not visible in the diff) are now skipped for duplicate completions. If nothing meaningful follows, this is benign; if shared bookkeeping follows (control state, transcript updates), it silently regresses.
Suggested fix: Preserve the original fall-through semantics: wrap only the emission (emitted.add + emit) in `if (!emitted.has(info.id)) { ... }` so outcomes.set and any subsequent loop-body logic still run for duplicates.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The original code guarded only the emission with && !emitted.has(info.id), so duplicate completed updates still fell through to the rest of the event-loop body. The new if (emitted.has(info.id)) continue aborts processing of the event entirely, so any statements after the assistant json block in the message.updated loop body (not visible in the diff) are now skipped for duplicate completions. If nothing meaningful follows, this is benign; if shared bookkeeping follows (control state, transcript updates), it silently regresses.

                outcomes.set(info.id, { status, finish: info.finish })\n                if (emitted.has(info.id)) continue\n                emitted.add(info.id)\n                const usage = terminalUsage(info)

emitted.add(info.id)
const usage = terminalUsage(info)

Expand Down Expand Up @@ -606,7 +682,7 @@ export const RunCommand = cmd({
cost: info.cost,
tokens: usage.tokens,
usageStatus: usage.usageStatus,
status: info.error?.name === "MessageAbortedError" ? "aborted" : info.error ? "error" : "completed",
status,
finish: info.finish,
})
}
Expand Down Expand Up @@ -711,6 +787,10 @@ export const RunCommand = cmd({
if (!control.current) process.exitCode = 1
invocation.error(props.error)
const classified = classifySessionError(props.error)
resolveRetry(
props.sessionID,
classified.reason === "interrupted" || classified.reason === "terminated" ? "aborted" : "failed",
)
// Structured session_error is the telemetry/CI channel for the
// primary session. The legacy "error" event below is the raw
// pass-through for both primary and child-session failures.
Expand Down Expand Up @@ -760,15 +840,23 @@ export const RunCommand = cmd({
}
}

if (event.type === "session.status" && event.properties.status.type === "idle") {
if (event.properties.sessionID === sessionID) {
break
if (event.type === "session.status") {
const status = event.properties.status
if (status.type === "retry" && event.properties.sessionID === sessionID) {
scheduleRetry(event.properties.sessionID, status)
}
if (childSessions.has(event.properties.sessionID)) {
emit("subagent_complete", {
subagentSessionID: event.properties.sessionID,
parentSessionID: sessionID,
})
if (status.type === "idle") {
if (event.properties.sessionID === sessionID) {
const retry = retries.get(event.properties.sessionID)
if (retry) resolveRetry(event.properties.sessionID, retryOutcome(retry))
break
}
if (childSessions.has(event.properties.sessionID)) {
emit("subagent_complete", {
subagentSessionID: event.properties.sessionID,
parentSessionID: sessionID,
})
}
}
}

Expand Down Expand Up @@ -862,6 +950,7 @@ export const RunCommand = cmd({
function interrupt(signal: Signals.Info) {
error ??= signal.message
invocation.error(signal.message)
resolveRetry(sessionID, "aborted")
report(signal.reason, String(signal.code), signal.message)
abort()
}
Expand All @@ -878,6 +967,7 @@ export const RunCommand = cmd({
const classified = classifySessionError(cause)
error ??= classified.message
invocation.error(cause)
resolveRetry(sessionID, "failed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Exit path mislabels cancelled retry as "failed".

--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -967,6 +967,9 @@
         const classified = classifySessionError(cause)
         error ??= classified.message
         invocation.error(cause)
-        resolveRetry(sessionID, "failed")
+        resolveRetry(
+          sessionID,
+          classified.reason === "interrupted" || classified.reason === "terminated" ? "aborted" : "failed",
+        )
         report(classified.reason, classified.code, classified.message)
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/cli/cmd/run.ts:970):

Problem: Exit path mislabels cancelled retry as "failed"
Detail: The fatal-exit handler hardcodes resolveRetry(sessionID, "failed") immediately after computing classified = classifySessionError(cause), which can classify the cause as cancellation ("interrupted" for the new MessageAbortedError branch, or "terminated"). The sibling session.error handler maps those same reasons to "aborted", and interrupt() maps signal cancellation to "aborted"; EVENTS.md states "cancellation is `aborted`". A cancellation surfacing through the exit path therefore emits retry_complete outcome "failed" while session_error reports "interrupted", contradicting the documented contract and polluting the retry-recovery-rate metric (denominator counts "failed" but excludes "aborted"). Repro: Given a run --format json session with a pending retry and no terminal message yet, When the session terminates via the fatal-exit path with a cancellation error (MessageAbortedError), Then retry_complete outcome is "failed" even though EVENTS.md requires "aborted".
Suggested fix: Mirror the session.error mapping: resolveRetry(sessionID, classified.reason === "interrupted" || classified.reason === "terminated" ? "aborted" : "failed"), or extract a shared retryOutcomeForClassified(reason) helper used by all three call sites.

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -967,6 +967,9 @@
         const classified = classifySessionError(cause)
         error ??= classified.message
         invocation.error(cause)
-        resolveRetry(sessionID, "failed")
+        resolveRetry(
+          sessionID,
+          classified.reason === "interrupted" || classified.reason === "terminated" ? "aborted" : "failed",
+        )
         report(classified.reason, classified.code, classified.message)

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The fatal-exit handler hardcodes resolveRetry(sessionID, "failed") immediately after computing classified = classifySessionError(cause), which can classify the cause as cancellation ("interrupted" for the new MessageAbortedError branch, or "terminated"). The sibling session.error handler maps those same reasons to "aborted", and interrupt() maps signal cancellation to "aborted"; EVENTS.md states "cancellation is aborted". A cancellation surfacing through the exit path therefore emits retry_complete outcome "failed" while session_error reports "interrupted", contradicting the documented contract and polluting the retry-recovery-rate metric (denominator counts "failed" but excludes "aborted"). Repro: Given a run --format json session with a pending retry and no terminal message yet, When the session terminates via the fatal-exit path with a cancellation error (MessageAbortedError), Then retry_complete outcome is "failed" even though EVENTS.md requires "aborted".

          const classified = classifySessionError(cause)
          error ??= classified.message
          invocation.error(cause)
        resolveRetry(sessionID, "failed")
        report(classified.reason, classified.code, classified.message)

report(classified.reason, classified.code, classified.message)
complete(error)
if (control.current) {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ export namespace SessionProcessor {
attempt,
message: retry,
next: Date.now() + delay,
retryID: crypto.randomUUID(),
messageID: input.assistantMessage.id,
providerID: input.model.providerID,
modelID: input.model.id,
reason: SessionRetry.reason(error),
delayMs: delay,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
continue
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"

export namespace SessionRetry {
export type Reason = "rate_limit" | "timeout" | "network" | "provider" | "unknown"

export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
Expand Down Expand Up @@ -99,4 +101,32 @@ export namespace SessionRetry {
return undefined
}
}

/** A bounded category for telemetry dimensions. This does not affect retry policy. */
export function reason(error: ReturnType<NamedError["toObject"]>): Reason {
const data = error.data && typeof error.data === "object" ? error.data : undefined
const status =
data && "statusCode" in data && typeof data.statusCode === "number"
? data.statusCode
: data && "status" in data && typeof data.status === "number"
? data.status
: undefined
const detail = (() => {
if (!data) return ""
const values = ["message", "responseBody"].flatMap((key) => {
if (!(key in data)) return []
const value = data[key as keyof typeof data]
return typeof value === "string" ? [value] : []
})
return values.join(" ")
})()

if (status === 429 || /rate[-_\s]?limit|too[-_\s]many[-_\s]requests/i.test(detail)) return "rate_limit"
if (status === 408 || /\btimeout\b|timed out|time out/i.test(detail)) return "timeout"
if (/network[-_\s]?error|fetch failed|connection|socket|ECONN|ENOTFOUND|EAI_AGAIN/i.test(detail)) return "network"
if ((status !== undefined && status >= 500) || /overloaded|unavailable|resource[-_\s]exhausted/i.test(detail)) {
return "provider"
}
return "unknown"
}
}
6 changes: 6 additions & 0 deletions packages/cli/src/session/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export namespace SessionStatus {
attempt: z.number(),
message: z.string(),
next: z.number(),
retryID: z.string().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unvalidated server fields re-emitted as metric dimensions.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/session/status.ts:17-22):

Problem: Unvalidated server fields re-emitted as metric dimensions
Detail: The new optional fields on the SSE-parsed retry status are unconstrained: retryID/messageID/providerID/modelID are bare z.string() and delayMs is a bare z.number() with no non-negative/finite/size bounds. In --attach mode these values cross the trust boundary from the attached server and are forwarded verbatim by run.ts scheduleRetry into retry_scheduled/retry_complete NDJSON events, where EVENTS.md designates providerID/modelID as metric dimensions and delayMs as a summed quantity per distinct retryID. A buggy or compromised server can inject arbitrary high-cardinality dimension labels or negative/huge delayMs values into CI measurement pipelines. The fallback path clamps via Math.max(0, next - Date.now()), but server-supplied delayMs passes through unchecked.
Suggested fix: Constrain at the boundary in status.ts (e.g. z.string().max(256) for the ID fields, z.number().nonnegative() for delayMs), or clamp/validate in scheduleRetry before emit.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new optional fields on the SSE-parsed retry status are unconstrained: retryID/messageID/providerID/modelID are bare z.string() and delayMs is a bare z.number() with no non-negative/finite/size bounds. In --attach mode these values cross the trust boundary from the attached server and are forwarded verbatim by run.ts scheduleRetry into retry_scheduled/retry_complete NDJSON events, where EVENTS.md designates providerID/modelID as metric dimensions and delayMs as a summed quantity per distinct retryID. A buggy or compromised server can inject arbitrary high-cardinality dimension labels or negative/huge delayMs values into CI measurement pipelines. The fallback path clamps via Math.max(0, next - Date.now()), but server-supplied delayMs passes through unchecked.

        retryID: z.string().optional(),\n        messageID: z.string().optional(),\n        providerID: z.string().optional(),\n        modelID: z.string().optional(),\n        reason: z.enum(["rate_limit", "timeout", "network", "provider", "unknown"]).optional(),\n        delayMs: z.number().optional(),

messageID: z.string().optional(),
providerID: z.string().optional(),
modelID: z.string().optional(),
reason: z.enum(["rate_limit", "timeout", "network", "provider", "unknown"]).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Closed reason enum vs documented open set.

Suggested change
reason: z.enum(["rate_limit", "timeout", "network", "provider", "unknown"]).optional(),
Parse leniently and narrow locally: reason: z.string().optional() (or z.enum([...]).catch("unknown")) in status.ts, then map unknown values to "unknown" in run.ts scheduleRetry, keeping SessionRetry.Reason as the emitted type.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #115, packages/cli/src/session/status.ts:21):

Problem: Closed reason enum vs documented open set
Detail: EVENTS.md's new retry_scheduled docs tell consumers to treat `reason` as an open set, and the PR's model is additive/forward-compatible fields, but the inbound parser pins a closed z.enum of five values (mirrored in the generated SDK union). A newer server emitting a new reason value fails schema validation in an older attached CLI — one of the explicit deployment shapes this PR supports via the messageID-null older-server path — turning an additive server change into a dropped/failed status parse rather than degradation to "unknown", silently losing retry telemetry.
Suggested fix: Parse leniently and narrow locally: reason: z.string().optional() (or z.enum([...]).catch("unknown")) in status.ts, then map unknown values to "unknown" in run.ts scheduleRetry, keeping SessionRetry.Reason as the emitted type.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

EVENTS.md's new retry_scheduled docs tell consumers to treat reason as an open set, and the PR's model is additive/forward-compatible fields, but the inbound parser pins a closed z.enum of five values (mirrored in the generated SDK union). A newer server emitting a new reason value fails schema validation in an older attached CLI — one of the explicit deployment shapes this PR supports via the messageID-null older-server path — turning an additive server change into a dropped/failed status parse rather than degradation to "unknown", silently losing retry telemetry.

        retryID: z.string().optional(),\n        messageID: z.string().optional(),\n        providerID: z.string().optional(),\n        modelID: z.string().optional(),\n        reason: z.enum(["rate_limit", "timeout", "network", "provider", "unknown"]).optional(),\n        delayMs: z.number().optional(),

delayMs: z.number().optional(),
}),
z.object({
type: z.literal("busy"),
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/test/cli/classify-session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ describe("classifySessionError (#63)", () => {
expect(classifySessionError(err).reason).toBe("timeout")
})

test("stored MessageAbortedError → interrupted", () => {
const res = classifySessionError({
name: "MessageAbortedError",
data: { message: "Session cancelled" },
})
expect(res.reason).toBe("interrupted")
})

test("heap OOM → oom", () => {
const err = new Error("JavaScript heap out of memory")
expect(classifySessionError(err).reason).toBe("oom")
Expand Down
Loading