Skip to content

feat: expose retry and execution outcome telemetry - #115

Open
byapparov wants to merge 5 commits into
mainfrom
backport/issue-110
Open

byapparov wants to merge 5 commits into
mainfrom
backport/issue-110

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Closes #110

Intent

Headless telemetry needs stable release, retry, and terminal-outcome dimensions so consumers can measure failures and recovery without introducing a second event stream or counting terminal summaries twice.

Expected Impact on Users

NDJSON consumers receive the CLI version on every invocation envelope and correlated retry lifecycle events with bounded categorical reasons, attempt identity, delay, and explicit recovered/failed/aborted/unknown outcomes.

Expected Outcomes

  • Pre-session failures still carry the CLI release identity.
  • Retry outcomes remain associated with their owning assistant message, including structured-output revisions and later-message failures or cancellation.
  • Missing terminal events remain censored/unknown rather than being counted as success.

Implementation

  • Enrich the existing retry status with optional correlation fields and emit paired retry_scheduled/retry_complete events.
  • Document metric denominators, safe dimensions, usage/cost joins, and child-session treatment; mirror the contract in the legacy SDK types.
  • Do not change retry policy, backoff, or maximum attempts.

Scope Caveat

This exposes measurement inputs only. It does not add automatic recovery, raw provider diagnostics, dashboards, or product-specific review metrics.

Test Plan

  • Fixtures cover retry recovery, exhaustion, structured-output correction, later-message failure, server cancellation, signals, and truncated streams.
  • Invocation lifecycle and usage documentation regressions remain covered.

Verification

  • 64 focused retry/lifecycle tests passed.
  • CLI and SDK typechecks, formatting, and diff checks passed.

Risks and Rollout

All new event fields are optional and additive. Consumers should treat unknown/censored outcomes explicitly and avoid using request or message IDs as metric labels.

@byapparov byapparov added this to the Enterprise Observability milestone Sep 14, 2026
@github-actions

Copy link
Copy Markdown

Review: retry & execution outcome telemetry (#110)

Overall this is well built: the new status fields are additive and optional, the free-text message from the retry status is deliberately dropped from NDJSON (and test-asserted), censoring semantics (idle → unknown, truncated stream stays unknown) match EVENTS.md, and MessageAbortedError → interrupted aligns with the documented session_error reason set. Two correctness items and a few minor notes below.

1. A late session error can flip an already-recovered retry to failed (medium)

packages/cli/src/cli/cmd/run.ts:790-793 resolves the pending retry with "failed" on any primary session.error, and reject() (run.ts:970) does the same — regardless of what the owning message already recorded in outcomes.

Reachable scenario:

  1. Retry scheduled for message A → attempt succeeds → A completes with finish: "tool-calls" (outcomes[A] = completed; the retry is intentionally held open for the structured-output correction window).
  2. Before message B's first message.updated arrives, a session-level error fires — e.g. model-not-found on the next turn (prompt.ts:356, prompt.ts:1845), a file-read failure during prompt assembly (prompt.ts:1211), or promptResult rejecting (→ reject()).
  3. resolveRetry(sid, "failed") emits retry_complete: failed even though the retried turn fully recovered.

This directly skews the retry-recovery-rate metric this PR introduces. The added test ("does not attribute a later message ProviderAuthError…") only covers the case where the next message starts before the error resolves the old retry.

Suggestion: when the retry's owning message already has a recorded terminal outcome, let retryOutcome(retry) win and only fall back to the classified session outcome when it would be unknown:

const recorded = retryOutcome(retry)
resolveRetry(props.sessionID, recorded !== "unknown" ? recorded : (classified.reason === "interrupted" || classified.reason === "terminated" ? "aborted" : "failed"))

This preserves every existing test: max-retries-exhausted and cancellation publish session.error before the terminal message update, so outcomes is unset there and the classified outcome still applies.

2. Legacy-server retry statuses cannot be deduped (low)

run.ts:591current.retryID === status.retryID compares a client-generated UUID against undefined when attached to an older server, so it never matches. Any redelivery of the same retry status (e.g. SSE replay on reconnect) emits an extra retry_scheduled/retry_complete(unknown) pair with a fresh retryID and a decayed delayMs. That also defeats the measurement contract's "sum delayMs once per distinct retryID" rule, since each redelivery mints a new UUID. Consider additionally deduping on attempt (or attempt + rough next) when retryID is absent.

Minor notes

  • outcomes (run.ts:557) grows for the lifetime of the run. It mirrors the pre-existing emitted set, so this is consistent, just flagging it for very long agentic runs.
  • SessionRetry.reason ordering (retry.ts:124-128): connection is matched before resource[-_\s]?exhausted/unavailable, so a provider message containing both words (e.g. "connection … resource_exhausted") is labeled network; time out is also fairly generic English. Bounded label only — no policy impact — but worth knowing when reading the dimensions.
  • retryOutcome counts only finish === "error" | "content-filter" as failure; other non-error finish reasons (e.g. length) count as recovered. If length (truncated output) should not count as recovery, that needs a case here.

Everything else — envelope (cliVersion on every event including invocation-phase failures), superseded-retry → failed, structured-output deferral, per-message scoping across tool turns, and the schema/SDK mirroring — looks correct and well covered by the new tests.

Reviewed SHA: a250a0c

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)

Comment thread EVENTS.md
}
```

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.

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  }

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      }

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)

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        }

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(),

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 7 · ⚪ 0 · 0/8 resolved

  • 🟡 EVENTS.md:222 — Doc: superseded retry not always "failed" in code
  • 🟡 packages/cli/src/cli/cmd/run.errors.ts:27-29 — New "interrupted" session_error reason undocumented
  • 🟡 packages/cli/src/cli/cmd/run.ts:553-555 — Retry.reason typed string; status shape re-declared
  • 🟡 packages/cli/src/cli/cmd/run.ts:589 — Legacy retry statuses cannot dedupe on replay
  • 🟡 packages/cli/src/cli/cmd/run.ts:648 — continue skips loop tail for duplicate completions
  • 🟠 packages/cli/src/cli/cmd/run.ts:970 — Exit path mislabels cancelled retry as "failed"
  • 🟡 packages/cli/src/session/status.ts:17-22 — Unvalidated server fields re-emitted as metric dimensions
  • 🟡 packages/cli/src/session/status.ts:21 — Closed reason enum vs documented open set
🤖 Fix all 8 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #115 (head branch).
Run the relevant tests/linters after each change.

1. EVENTS.md:222 — 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.
2. packages/cli/src/cli/cmd/run.errors.ts:27-29 — 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.
3. packages/cli/src/cli/cmd/run.ts:553-555 — 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.
4. packages/cli/src/cli/cmd/run.ts:589 — 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.
5. packages/cli/src/cli/cmd/run.ts:648 — 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.
6. packages/cli/src/cli/cmd/run.ts:970 — 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.
7. packages/cli/src/session/status.ts:17-22 — 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.
8. packages/cli/src/session/status.ts:21 — 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.
📋 Out-of-diff findings (8)
Sev Location Finding
🟡 EVENTS.md:222 Doc: superseded retry not always "failed" in code
🟡 packages/cli/src/cli/cmd/run.errors.ts:27-29 New "interrupted" session_error reason undocumented
🟡 packages/cli/src/cli/cmd/run.ts:553-555 Retry.reason typed string; status shape re-declared
🟡 packages/cli/src/cli/cmd/run.ts:589 Legacy retry statuses cannot dedupe on replay
🟡 packages/cli/src/cli/cmd/run.ts:648 continue skips loop tail for duplicate completions
🟠 packages/cli/src/cli/cmd/run.ts:970 Exit path mislabels cancelled retry as "failed"
🟡 packages/cli/src/session/status.ts:17-22 Unvalidated server fields re-emitted as metric dimensions
🟡 packages/cli/src/session/status.ts:21 Closed reason enum vs documented open set

Reviewed 14 files · 0 inline · view all 8 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov byapparov self-assigned this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose execution outcome and retry measurement inputs

1 participant