Skip to content

Handle headless signals as graceful cancellation#98

Open
byapparov wants to merge 4 commits into
mainfrom
fix/issue-94-signal-cancellation
Open

Handle headless signals as graceful cancellation#98
byapparov wants to merge 4 commits into
mainfrom
fix/issue-94-signal-cancellation

Conversation

@byapparov

@byapparov byapparov commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #94

Intent

Translate SIGINT and SIGTERM during headless runs into bounded, observable cancellation instead of abrupt, ambiguous termination, while keeping the command lifecycle straightforward to read and maintain.

Expected outcomes

  • The first signal requests cancellation and preserves conventional exit codes 130 or 143.
  • The NDJSON stream emits a stable interrupted or terminated error followed by one completion sequence.
  • Cancellation is attempted once and shutdown is bounded by a grace period.
  • A second signal forces immediate termination.
  • Accepted terminal output is flushed when the downstream pipe remains available.
  • Signal and failure decisions are expressed through small, cohesive helpers rather than repeated nested branches.

Expected impact

Shells, supervisors, and automation receive consistent exit codes and terminal events for interrupted work, reducing sessions that appear stuck or successful after cancellation. Signal handling remains scoped to an active run, and future changes have one clear location for reporting, completion, abort, interruption, expiry, and failure behavior.

Summary

  • cancel active headless sessions on the first SIGINT or SIGTERM and preserve conventional exit codes
  • emit explicit interrupted / terminated session errors before the normal session_complete path
  • bound graceful shutdown, hard-exit on a second signal, flush accepted stdout records, and remove handlers after completion
  • use the shared stdout/shutdown implementation from merged PR fix(cli): flush NDJSON before forced exit #95 instead of maintaining a signal-local writer
  • keep the execution path linear by centralizing report-once, complete-once, abort-once, interruption, expiry, and non-signal failure decisions
  • document the expanded v1 error reasons and signal-derived codes

Verification

  • bun test from packages/cli — 1,347 passed, 7 skipped, 0 failed
  • focused signal, stdout, schema, and usage tests — 23 passed, 0 failed
  • bun run typecheck from packages/cli
  • workspace bun run typecheck — 6 tasks successful
  • Prettier check and git diff --check
  • real subprocess coverage for SIGINT, SIGTERM, second-signal hard exit, grace expiry, event ordering, handler cleanup, closed stdout, abort count, and exit codes

Risk

Signal handlers are installed only after a real session starts and are scoped to that run. SIGKILL and supervisors that provide no grace period remain outside the guarantee. The branch is rebased onto the shared stdout/shutdown implementation already on main; an unexpected flush error records a failure without replacing an existing signal exit code.

export const SCHEMA_VERSION = "1"

export type SessionErrorReason = "rate_limit" | "auth" | "timeout" | "oom" | "provider" | "unknown"
export type SessionErrorReason =

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 SessionErrorReason values not propagated to SDK/plugin.

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

Problem: New SessionErrorReason values not propagated to SDK/plugin
Detail: `SessionErrorReason` is widened with `"interrupted" | "terminated"`, but the diff touches no consumer outside `packages/cli`. `run.errors.ts` exports the public NDJSON event contract (`SCHEMA_VERSION` + types); the SDK and plugin packages are typical co-change partners of `run.ts` for event schemas. If either the SDK or plugin re-exports, mirrors, or generates from `SessionErrorReason`, consumers using exhaustive `switch` on `reason` will (a) fail to type-check against the widened union, and (b) see no matching `SCHEMA_VERSION` bump (still `"1"`) signaling the new discriminators. `EVENTS.md` documents the values but nothing in the diff confirms SDK/plugin types or generated OpenAPI/JSON-schema follow. Verify by grepping both packages for `SessionErrorReason` / `session_error` / `rate_limit`.
Suggested fix: Audit `packages/sdk/**` and `packages/plugin/**` for re-exports, duplicates, or generated schemas derived from `SessionErrorReason` (and the `session_error` payload). Add `"interrupted"` and `"terminated"` wherever the union is mirrored. If schema is codegen-driven from `run.errors.ts`, regenerate and commit. If the repo's versioning policy treats enum widening as breaking, bump `SCHEMA_VERSION` and document migration; otherwise add a changelog note that v1 reason is additive.

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

SessionErrorReason is widened with "interrupted" | "terminated", but the diff touches no consumer outside packages/cli. run.errors.ts exports the public NDJSON event contract (SCHEMA_VERSION + types); the SDK and plugin packages are typical co-change partners of run.ts for event schemas. If either the SDK or plugin re-exports, mirrors, or generates from SessionErrorReason, consumers using exhaustive switch on reason will (a) fail to type-check against the widened union, and (b) see no matching SCHEMA_VERSION bump (still "1") signaling the new discriminators. EVENTS.md documents the values but nothing in the diff confirms SDK/plugin types or generated OpenAPI/JSON-schema follow. Verify by grepping both packages for SessionErrorReason / session_error / rate_limit.

export const SCHEMA_VERSION = "1"

export type SessionErrorReason =
  | "rate_limit"
  | "auth"
  | "timeout"
  | "oom"
  | "provider"
  | "interrupted"
  | "terminated"
  | "unknown"

const share = await Session.share(opts.sessionID)
return { data: { share } }
},
abort(opts: any) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 abort shim drops SessionPrompt.cancel returned promise.

--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -955,6 +955,7 @@
             return { data: { share } }
           },
           abort(opts: any) {
-            SessionPrompt.cancel(opts.sessionID)
+            return SessionPrompt.cancel(opts.sessionID)
           },
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:958-960):

Problem: abort shim drops SessionPrompt.cancel returned promise
Detail: The added shim `abort(opts: any) { SessionPrompt.cancel(opts.sessionID) }` has no return statement. If `SessionPrompt.cancel` returns a Promise (async cancel implementation), that promise is discarded by the shim. The caller `cancel()` does `Promise.resolve(sdk.session.abort({...})).catch(...)` — since the shim returns undefined, `Promise.resolve(undefined)` resolves immediately and the `.catch` never fires. Any async rejection from `SessionPrompt.cancel` (abort endpoint 5xx, network error) becomes an unhandled promise rejection and is silently lost from the signal-cancellation observability path. In attach mode this shim IS `sdk.session.abort`, so `cancel()`'s entire error-handling path is dead.
Suggested fix: abort(opts: any) {
  return SessionPrompt.cancel(opts.sessionID)
},

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -955,6 +955,7 @@
             return { data: { share } }
           },
           abort(opts: any) {
-            SessionPrompt.cancel(opts.sessionID)
+            return SessionPrompt.cancel(opts.sessionID)
           },


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 added shim abort(opts: any) { SessionPrompt.cancel(opts.sessionID) } has no return statement. If SessionPrompt.cancel returns a Promise (async cancel implementation), that promise is discarded by the shim. The caller cancel() does Promise.resolve(sdk.session.abort({...})).catch(...) — since the shim returns undefined, Promise.resolve(undefined) resolves immediately and the .catch never fires. Any async rejection from SessionPrompt.cancel (abort endpoint 5xx, network error) becomes an unhandled promise rejection and is silently lost from the signal-cancellation observability path. In attach mode this shim IS sdk.session.abort, so cancel()'s entire error-handling path is dead.

            const share = await Session.share(opts.sessionID)
            return { data: { share } }
          },
          abort(opts: any) {
            SessionPrompt.cancel(opts.sessionID)
          },
          async prompt(opts: any) {

@@ -784,6 +785,32 @@ export const RunCommand = cmd({
permissions: PermissionNext.merge(agentInfo.permission, rules),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 cancel() misses sync throws from sdk.session.abort.

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

Problem: cancel() misses sync throws from sdk.session.abort
Detail: `Promise.resolve(sdk.session.abort({ sessionID }))` evaluates the argument expression FIRST, then wraps the result. If `sdk.session.abort` throws synchronously — e.g. `sdk.session` has no `abort` method in non-attach mode (the abort shim is only added inside the `if (args.attach)` block at line 958), or `SessionPrompt.cancel` throws sync — the throw escapes `cancel()` entirely, propagates into the signals callback and then into `handle()` in signals.ts. Because `handle()` sets `state.current = signal` BEFORE calling `cancel(signal)`, `state.current` is set but `state.resolve?.(signal)` on the next line is never reached, so the `received` promise never resolves. Net effect: `run.ts`'s `if (control.current) { await loopDone; ... }` branch is taken, but `loopDone` may never resolve because the real abort never fired; the 5s grace timer then force-exits with no `session_complete`. The `.catch()` is dead code for the sync-throw case.
Suggested fix: function cancel() {
  Promise.resolve()
    .then(() => sdk.session.abort({ sessionID }))
    .catch((e) => {
      console.error(e)
    })
}

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

Promise.resolve(sdk.session.abort({ sessionID })) evaluates the argument expression FIRST, then wraps the result. If sdk.session.abort throws synchronously — e.g. sdk.session has no abort method in non-attach mode (the abort shim is only added inside the if (args.attach) block at line 958), or SessionPrompt.cancel throws sync — the throw escapes cancel() entirely, propagates into the signals callback and then into handle() in signals.ts. Because handle() sets state.current = signal BEFORE calling cancel(signal), state.current is set but state.resolve?.(signal) on the next line is never reached, so the received promise never resolves. Net effect: run.ts's if (control.current) { await loopDone; ... } branch is taken, but loopDone may never resolve because the real abort never fired; the 5s grace timer then force-exits with no session_complete. The .catch() is dead code for the sync-throw case.

      function cancel() {
        Promise.resolve(sdk.session.abort({ sessionID })).catch((e) => {
          console.error(e)
        })
      }

      using control = signals((signal) => {

Comment thread packages/cli/src/cli/signals.ts Outdated
}
state.current = signal
process.exitCode = signal.code
state.timer = setTimeout(() => process.exit(signal.code), grace)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Grace timer hard-exits without emitting session_complete.

Suggested change
state.timer = setTimeout(() => process.exit(signal.code), grace)
Have the grace-timer callback emit a terminal `session_complete` (or invoke a registered onGraceExpired hook) before `process.exit`, OR have run.ts race `await loopDone` against a pre-timeout that emits `session_complete` and flushes before delegating to the hard exit.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/signals.ts:42):

Problem: Grace timer hard-exits without emitting session_complete
Detail: The grace timer `setTimeout(() => process.exit(signal.code), grace)` is a hard fallback. In run.ts the signal handler emits `session_error` but NOT `session_complete` — `session_complete` is only produced when `loopDone` resolves naturally after a successful abort. If `sdk.session.abort` is slow, unacked, or throws (see related finding on `cancel()`), `loopDone` never resolves within the 5s grace window; the timer fires `process.exit` and the NDJSON stream ends after `session_error` with no `session_complete`. This violates the documented contract in EVENTS.md ("Emitted once when the session ends (success or failure)"). The new test `an expired grace period causes hard termination` explicitly asserts only `["session_error"]` is emitted, confirming `session_complete` is skipped by design on this path. Downstream alerting that waits on `session_complete` will hang or treat the run as still-active.
Suggested fix: Have the grace-timer callback emit a terminal `session_complete` (or invoke a registered onGraceExpired hook) before `process.exit`, OR have run.ts race `await loopDone` against a pre-timeout that emits `session_complete` and flushes before delegating to the hard exit.

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 grace timer setTimeout(() => process.exit(signal.code), grace) is a hard fallback. In run.ts the signal handler emits session_error but NOT session_completesession_complete is only produced when loopDone resolves naturally after a successful abort. If sdk.session.abort is slow, unacked, or throws (see related finding on cancel()), loopDone never resolves within the 5s grace window; the timer fires process.exit and the NDJSON stream ends after session_error with no session_complete. This violates the documented contract in EVENTS.md ("Emitted once when the session ends (success or failure)"). The new test an expired grace period causes hard termination explicitly asserts only ["session_error"] is emitted, confirming session_complete is skipped by design on this path. Downstream alerting that waits on session_complete will hang or treat the run as still-active.

    state.current = signal
    process.exitCode = signal.code
    state.timer = setTimeout(() => process.exit(signal.code), grace)
    cancel(signal)
    state.resolve?.(signal)

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
cancel()
})

function flush() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 flush() empty-string write may not drain stdout on Bun.

Suggested change
function flush() {
Drain via a real write of a known non-empty sentinel byte, or capture the boolean return of `process.stdout.write` and, when false, await the `'drain'` event (`process.stdout.once("drain", resolve)`) the canonical Node drain pattern that Bun also honors.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:807-810):

Problem: flush() empty-string write may not drain stdout on Bun
Detail: `flush()` uses `process.stdout.write("", () => resolve())` to wait for prior NDJSON writes to drain. Empty-string write semantics differ across runtimes: on Node TTY stdout, `write` is effectively synchronous and the callback fires on nextTick regardless of pending data; on Node pipe stdout, an empty chunk is queued behind prior writes so drain order is preserved. This repo targets Bun (tests invoke `bun run`), where `process.stdout.write` is a re-implementation and there is no documented guarantee that an empty-string write blocks the callback until prior buffered data is handed to the OS. The `await flush()` before `return` (and before `using` dispose) may therefore resolve before `session_error`/`session_complete` bytes leave the process, risking truncated NDJSON output when the caller exits immediately after.
Suggested fix: Drain via a real write of a known non-empty sentinel byte, or capture the boolean return of `process.stdout.write` and, when false, await the `'drain'` event (`process.stdout.once("drain", resolve)`) — the canonical Node drain pattern that Bun also honors.

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

flush() uses process.stdout.write("", () => resolve()) to wait for prior NDJSON writes to drain. Empty-string write semantics differ across runtimes: on Node TTY stdout, write is effectively synchronous and the callback fires on nextTick regardless of pending data; on Node pipe stdout, an empty chunk is queued behind prior writes so drain order is preserved. This repo targets Bun (tests invoke bun run), where process.stdout.write is a re-implementation and there is no documented guarantee that an empty-string write blocks the callback until prior buffered data is handed to the OS. The await flush() before return (and before using dispose) may therefore resolve before session_error/session_complete bytes leave the process, risking truncated NDJSON output when the caller exits immediately after.

      function flush() {
        if (!control.current) return Promise.resolve()
        return new Promise<void>((resolve) => {
          process.stdout.write("", () => resolve())
        })
      }

@@ -784,6 +785,32 @@ export const RunCommand = cmd({
permissions: PermissionNext.merge(agentInfo.permission, rules),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Raw SDK abort error dumped to stderr via console.error.

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

Problem: Raw SDK abort error dumped to stderr via console.error
Detail: The signal-cancel path catches the rejection from `sdk.session.abort(...)` and writes the entire error object to stderr with `console.error(e)`. SDK/network errors can carry provider base URLs, request headers, redacted-but-not-safe stack traces, or environment-derived diagnostics that should not surface on the user's terminal or be captured by parent process logs. This also runs inside a signal handler, so the output is unsanitized and unstructured.
Suggested fix: Log a fixed, safe message and forward details only to a debug sink, e.g. `.catch((e) => { debug?.(e); console.error("abort failed") })`, or run the error through the existing `classifySessionError`/redaction helper before printing.

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 signal-cancel path catches the rejection from sdk.session.abort(...) and writes the entire error object to stderr with console.error(e). SDK/network errors can carry provider base URLs, request headers, redacted-but-not-safe stack traces, or environment-derived diagnostics that should not surface on the user's terminal or be captured by parent process logs. This also runs inside a signal handler, so the output is unsanitized and unstructured.

      function cancel() {
        Promise.resolve(sdk.session.abort({ sessionID })).catch((e) => {
          console.error(e)
        })
      }

console.error(e)
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 session_complete.error mixes provider and signal text.

Suggested change
Either skip the error append when `sessionErrorEmitted` is already true (the prior error already owns `session_complete.error`), or emit the signal's `session_error` with precedence over the suppressed provider one.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:793-801):

Problem: session_complete.error mixes provider and signal text
Detail: When a non-fatal provider error already populated `error` and emitted `session_error` (`sessionErrorEmitted=true`), a subsequent SIGINT/SIGTERM still executes `error = error ? error + EOL + signal.message : signal.message`, appending the signal message to the existing provider error string. The `session_error` event is correctly suppressed by the guard, but `session_complete.error` ends up as `'provider error\nSession interrupted by SIGINT'` while `session_error.reason` remains `'provider'`. The two fields now disagree about why the session ended. EVENTS.md says non-fatal errors "may also appear concatenated in `session_complete.error`", but mixing a real provider error with a signal termination message in the same field while suppressing the signal's own `session_error` is surprising for consumers keying alerts on `session_complete.error` content.
Suggested fix: Either skip the error append when `sessionErrorEmitted` is already true (the prior error already owns `session_complete.error`), or emit the signal's `session_error` with precedence over the suppressed provider one.

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

When a non-fatal provider error already populated error and emitted session_error (sessionErrorEmitted=true), a subsequent SIGINT/SIGTERM still executes error = error ? error + EOL + signal.message : signal.message, appending the signal message to the existing provider error string. The session_error event is correctly suppressed by the guard, but session_complete.error ends up as 'provider error\nSession interrupted by SIGINT' while session_error.reason remains 'provider'. The two fields now disagree about why the session ended. EVENTS.md says non-fatal errors "may also appear concatenated in session_complete.error", but mixing a real provider error with a signal termination message in the same field while suppressing the signal's own session_error is surprising for consumers keying alerts on session_complete.error content.

      using control = signals((signal) => {
        error = error ? error + EOL + signal.message : signal.message
        if (!sessionErrorEmitted) {
          sessionErrorEmitted = true
          emit("session_error", {
            reason: signal.reason,
            code: String(signal.code),
            message: signal.message,
          })
        }
        cancel()
      })

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

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

  • 🟠 packages/cli/src/cli/cmd/run.errors.ts:3-10 — New SessionErrorReason values not propagated to SDK/plugin
  • 🟠 packages/cli/src/cli/cmd/run.ts:786-789 — cancel() misses sync throws from sdk.session.abort
  • 🟡 packages/cli/src/cli/cmd/run.ts:786-789 — Raw SDK abort error dumped to stderr via console.error
  • 🟡 packages/cli/src/cli/cmd/run.ts:793-801 — session_complete.error mixes provider and signal text
  • 🟡 packages/cli/src/cli/cmd/run.ts:807-810 — flush() empty-string write may not drain stdout on Bun
  • 🟠 packages/cli/src/cli/cmd/run.ts:958-960 — abort shim drops SessionPrompt.cancel returned promise
  • 🟠 packages/cli/src/cli/signals.ts:42 — Grace timer hard-exits without emitting session_complete
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #98 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.errors.ts:3-10 — New SessionErrorReason values not propagated to SDK/plugin
   Detail: `SessionErrorReason` is widened with `"interrupted" | "terminated"`, but the diff touches no consumer outside `packages/cli`. `run.errors.ts` exports the public NDJSON event contract (`SCHEMA_VERSION` + types); the SDK and plugin packages are typical co-change partners of `run.ts` for event schemas. If either the SDK or plugin re-exports, mirrors, or generates from `SessionErrorReason`, consumers using exhaustive `switch` on `reason` will (a) fail to type-check against the widened union, and (b) see no matching `SCHEMA_VERSION` bump (still `"1"`) signaling the new discriminators. `EVENTS.md` documents the values but nothing in the diff confirms SDK/plugin types or generated OpenAPI/JSON-schema follow. Verify by grepping both packages for `SessionErrorReason` / `session_error` / `rate_limit`.
   Suggested fix: Audit `packages/sdk/**` and `packages/plugin/**` for re-exports, duplicates, or generated schemas derived from `SessionErrorReason` (and the `session_error` payload). Add `"interrupted"` and `"terminated"` wherever the union is mirrored. If schema is codegen-driven from `run.errors.ts`, regenerate and commit. If the repo's versioning policy treats enum widening as breaking, bump `SCHEMA_VERSION` and document migration; otherwise add a changelog note that v1 reason is additive.
2. packages/cli/src/cli/cmd/run.ts:786-789 — cancel() misses sync throws from sdk.session.abort
   Detail: `Promise.resolve(sdk.session.abort({ sessionID }))` evaluates the argument expression FIRST, then wraps the result. If `sdk.session.abort` throws synchronously — e.g. `sdk.session` has no `abort` method in non-attach mode (the abort shim is only added inside the `if (args.attach)` block at line 958), or `SessionPrompt.cancel` throws sync — the throw escapes `cancel()` entirely, propagates into the signals callback and then into `handle()` in signals.ts. Because `handle()` sets `state.current = signal` BEFORE calling `cancel(signal)`, `state.current` is set but `state.resolve?.(signal)` on the next line is never reached, so the `received` promise never resolves. Net effect: `run.ts`'s `if (control.current) { await loopDone; ... }` branch is taken, but `loopDone` may never resolve because the real abort never fired; the 5s grace timer then force-exits with no `session_complete`. The `.catch()` is dead code for the sync-throw case.
   Suggested fix: function cancel() {
  Promise.resolve()
    .then(() => sdk.session.abort({ sessionID }))
    .catch((e) => {
      console.error(e)
    })
}
3. packages/cli/src/cli/cmd/run.ts:786-789 — Raw SDK abort error dumped to stderr via console.error
   Detail: The signal-cancel path catches the rejection from `sdk.session.abort(...)` and writes the entire error object to stderr with `console.error(e)`. SDK/network errors can carry provider base URLs, request headers, redacted-but-not-safe stack traces, or environment-derived diagnostics that should not surface on the user's terminal or be captured by parent process logs. This also runs inside a signal handler, so the output is unsanitized and unstructured.
   Suggested fix: Log a fixed, safe message and forward details only to a debug sink, e.g. `.catch((e) => { debug?.(e); console.error("abort failed") })`, or run the error through the existing `classifySessionError`/redaction helper before printing.
4. packages/cli/src/cli/cmd/run.ts:793-801 — session_complete.error mixes provider and signal text
   Detail: When a non-fatal provider error already populated `error` and emitted `session_error` (`sessionErrorEmitted=true`), a subsequent SIGINT/SIGTERM still executes `error = error ? error + EOL + signal.message : signal.message`, appending the signal message to the existing provider error string. The `session_error` event is correctly suppressed by the guard, but `session_complete.error` ends up as `'provider error\nSession interrupted by SIGINT'` while `session_error.reason` remains `'provider'`. The two fields now disagree about why the session ended. EVENTS.md says non-fatal errors "may also appear concatenated in `session_complete.error`", but mixing a real provider error with a signal termination message in the same field while suppressing the signal's own `session_error` is surprising for consumers keying alerts on `session_complete.error` content.
   Suggested fix: Either skip the error append when `sessionErrorEmitted` is already true (the prior error already owns `session_complete.error`), or emit the signal's `session_error` with precedence over the suppressed provider one.
5. packages/cli/src/cli/cmd/run.ts:807-810 — flush() empty-string write may not drain stdout on Bun
   Detail: `flush()` uses `process.stdout.write("", () => resolve())` to wait for prior NDJSON writes to drain. Empty-string write semantics differ across runtimes: on Node TTY stdout, `write` is effectively synchronous and the callback fires on nextTick regardless of pending data; on Node pipe stdout, an empty chunk is queued behind prior writes so drain order is preserved. This repo targets Bun (tests invoke `bun run`), where `process.stdout.write` is a re-implementation and there is no documented guarantee that an empty-string write blocks the callback until prior buffered data is handed to the OS. The `await flush()` before `return` (and before `using` dispose) may therefore resolve before `session_error`/`session_complete` bytes leave the process, risking truncated NDJSON output when the caller exits immediately after.
   Suggested fix: Drain via a real write of a known non-empty sentinel byte, or capture the boolean return of `process.stdout.write` and, when false, await the `'drain'` event (`process.stdout.once("drain", resolve)`) — the canonical Node drain pattern that Bun also honors.
6. packages/cli/src/cli/cmd/run.ts:958-960 — abort shim drops SessionPrompt.cancel returned promise
   Detail: The added shim `abort(opts: any) { SessionPrompt.cancel(opts.sessionID) }` has no return statement. If `SessionPrompt.cancel` returns a Promise (async cancel implementation), that promise is discarded by the shim. The caller `cancel()` does `Promise.resolve(sdk.session.abort({...})).catch(...)` — since the shim returns undefined, `Promise.resolve(undefined)` resolves immediately and the `.catch` never fires. Any async rejection from `SessionPrompt.cancel` (abort endpoint 5xx, network error) becomes an unhandled promise rejection and is silently lost from the signal-cancellation observability path. In attach mode this shim IS `sdk.session.abort`, so `cancel()`'s entire error-handling path is dead.
   Suggested fix: abort(opts: any) {
  return SessionPrompt.cancel(opts.sessionID)
},
7. packages/cli/src/cli/signals.ts:42 — Grace timer hard-exits without emitting session_complete
   Detail: The grace timer `setTimeout(() => process.exit(signal.code), grace)` is a hard fallback. In run.ts the signal handler emits `session_error` but NOT `session_complete` — `session_complete` is only produced when `loopDone` resolves naturally after a successful abort. If `sdk.session.abort` is slow, unacked, or throws (see related finding on `cancel()`), `loopDone` never resolves within the 5s grace window; the timer fires `process.exit` and the NDJSON stream ends after `session_error` with no `session_complete`. This violates the documented contract in EVENTS.md ("Emitted once when the session ends (success or failure)"). The new test `an expired grace period causes hard termination` explicitly asserts only `["session_error"]` is emitted, confirming `session_complete` is skipped by design on this path. Downstream alerting that waits on `session_complete` will hang or treat the run as still-active.
   Suggested fix: Have the grace-timer callback emit a terminal `session_complete` (or invoke a registered onGraceExpired hook) before `process.exit`, OR have run.ts race `await loopDone` against a pre-timeout that emits `session_complete` and flushes before delegating to the hard exit.
📋 Out-of-diff findings (7)
Sev Location Finding
🟠 packages/cli/src/cli/cmd/run.errors.ts:3-10 New SessionErrorReason values not propagated to SDK/plugin
🟠 packages/cli/src/cli/cmd/run.ts:786-789 cancel() misses sync throws from sdk.session.abort
🟡 packages/cli/src/cli/cmd/run.ts:786-789 Raw SDK abort error dumped to stderr via console.error
🟡 packages/cli/src/cli/cmd/run.ts:793-801 session_complete.error mixes provider and signal text
🟡 packages/cli/src/cli/cmd/run.ts:807-810 flush() empty-string write may not drain stdout on Bun
🟠 packages/cli/src/cli/cmd/run.ts:958-960 abort shim drops SessionPrompt.cancel returned promise
🟠 packages/cli/src/cli/signals.ts:42 Grace timer hard-exits without emitting session_complete

Reviewed 6 files · 0 inline · view all 7 findings ↗


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

@byapparov

Copy link
Copy Markdown
Contributor Author

Review finding judgments and remediation

Judged against original review head 115a7864b; remediated at 8b7107d50.

Finding Verdict / action Resolution Verification
SDK/plugin propagation FALSE · IGNORE Repository-wide audit found no SessionErrorReason, session_error, or reason-enum mirror in packages/sdk or packages/plugin; generated SDK schemas are unrelated. EVENTS.md now explicitly records unknown enum-like strings as additive v1 values, so no schema bump/codegen is required. rg audit returned no mirrors; workspace typecheck PASS
abort shim return FALSE · IGNORE SessionPrompt.cancel returns void at this head, so no current promise was dropped. The shim now returns its result anyway to preserve future compatibility. package + workspace typecheck PASS
synchronous abort throw TRUE · FIX Abort invocation now starts inside a promise continuation, containing both synchronous throws and asynchronous rejections. bun test test/cli/graceful-signals.test.ts — PASS, sync + async cases
grace expiry misses terminal event TRUE · FIX Grace expiry invokes a bounded terminal hook; run emits one guarded session_complete, waits for its real write callback, then hard-exits. A second signal remains immediate. graceful expiry subprocess test — PASS
empty-write flush TRUE · FIX Removed the empty-string write. Signal shutdown now awaits the callback promise belonging to the last real NDJSON event write. PR #95/#91 remains the owner of global serialization/backpressure; this PR is independently correct without duplicating that writer. SIGINT/SIGTERM subprocess ordering tests — PASS
raw abort error on stderr TRUE · FIX The rejection reason is deliberately not forwarded; only the fixed safe log message session abort failed is recorded. sync + async private-detail containment tests — PASS
provider/signal error mixing TRUE · FIX The first structured terminal error owns session_complete.error; a later signal no longer appends conflicting text. Completion emission is guarded globally. provider-error-then-SIGTERM subprocess test — PASS

Verification

  • Focused CLI suite: 25 passed, 0 failed
  • Full packages/cli suite: 1,333 passed, 7 skipped, 0 failed
  • bun run typecheck in packages/cli: PASS
  • bun turbo typecheck: 6/6 tasks successful
  • git diff --check: PASS

One automated re-review round is already running for 8b7107d50; no additional review was requested.

}

function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 flush() awaits a write callback that hides stdout errors.

Suggested change
if (args.format === "json") {
Chain promises instead of reassigning (`output = output.then(() => writeChunk(...))`) so any rejection propagates to `await flush()` and into the expire onRejected branch; and register `process.stdout.on('error', (e) => Log.Default.error('stdout write failed'))` once at module load so EPIPE does not crash or silently lose the terminal events.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:471-475):

Problem: flush() awaits a write callback that hides stdout errors
Detail: The new `emit` wraps `process.stdout.write` in a promise resolved by the write callback, and the signal expire path does `await flush()` (returning `output`) before `process.exit(signal.code)`. The `writable.write` callback only signals that the chunk was handed to the kernel -- it does NOT propagate write errors (EPIPE / EIO), which surface via stdout's 'error' event, for which no listener is registered in this scope. When the consumer closes the pipe early (`aictrl run --format json | head`, parent crash, closed PTY), `await flush()` resolves "successfully" even though the `session_error`/`session_complete` lines were never delivered. The process then exits 130/143 reporting a graceful cancellation that downstream orchestrators never observed, defeating the purpose of the structured event. Additionally, reassigning `output = new Promise(...)` per emit (instead of chaining) means a rejection from an earlier write is dropped rather than propagated to `await flush()`. If `JSON.stringify` or `write` ever throws synchronously inside the executor (destroyed stream), `output` becomes a rejected promise that nothing `.catch`es synchronously, raising an unhandledRejection during signal handling.
Suggested fix: Chain promises instead of reassigning (`output = output.then(() => writeChunk(...))`) so any rejection propagates to `await flush()` and into the expire onRejected branch; and register `process.stdout.on('error', (e) => Log.Default.error('stdout write failed'))` once at module load so EPIPE does not crash or silently lose the terminal events.

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 emit wraps process.stdout.write in a promise resolved by the write callback, and the signal expire path does await flush() (returning output) before process.exit(signal.code). The writable.write callback only signals that the chunk was handed to the kernel -- it does NOT propagate write errors (EPIPE / EIO), which surface via stdout's 'error' event, for which no listener is registered in this scope. When the consumer closes the pipe early (aictrl run --format json | head, parent crash, closed PTY), await flush() resolves "successfully" even though the session_error/session_complete lines were never delivered. The process then exits 130/143 reporting a graceful cancellation that downstream orchestrators never observed, defeating the purpose of the structured event. Additionally, reassigning output = new Promise(...) per emit (instead of chaining) means a rejection from an earlier write is dropped rather than propagated to await flush(). If JSON.stringify or write ever throws synchronously inside the executor (destroyed stream), output becomes a rejected promise that nothing .catches synchronously, raising an unhandledRejection during signal handling.

      function emit(type: string, data: Record<string, unknown>) {
        if (args.format === "json") {
          output = new Promise<void>((resolve) => {
            process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL, () =>
              resolve(),
            )
          })
          return true

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
})
complete(error ?? classified.message)
console.error(e)
process.exitCode = 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Signal exit code overwritten to 1 if abort rejects.

Suggested change
process.exitCode = 1
Gate the fallback on the absence of a signal in BOTH catch handlers: `if (!control.current) process.exitCode = 1` (and wrap `console.error(e)` in the same guard) so an intentional cancellation does not overwrite the 130/143 exit code or print a misleading stack.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:881-882):

Problem: Signal exit code overwritten to 1 if abort rejects
Detail: Both rejection handlers unconditionally run `process.exitCode = 1` (loopDone.catch at ~L881, and the symmetric promptResult.catch at ~L935). When a SIGINT/SIGTERM fires, the signal handler sets `process.exitCode = 130/143` and calls `abort()` -> `sdk.session.abort` -> `SessionPrompt.cancel(sessionID)`. If that cancellation propagates as a rejection of `loop()` or `promptResult` (a normal way for cancellation to surface), the `.catch` runs AFTER the handler already set 130/143 and clobbers it back to 1. The early-return path `if (control.current) { await loopDone; await flush(); return }` then returns, the `using control` dispose clears the grace timer so `expire` never fires, and the process exits with code 1 -- not the conventional 130/143 that is this PR's stated contract. The new integration test does NOT catch this: the mock `/abort` returns `Response.json(true)` + an idle `session.status`, so `loop()` resolves cleanly; any real SDK abort that surfaces as a rejection exposes the bug. There is no `control.current` guard in either catch. Secondary noise: `console.error(e)` in the same handlers would also dump the raw cancellation error to stderr after an intentional Ctrl+C.
Suggested fix: Gate the fallback on the absence of a signal in BOTH catch handlers: `if (!control.current) process.exitCode = 1` (and wrap `console.error(e)` in the same guard) so an intentional cancellation does not overwrite the 130/143 exit code or print a misleading stack.

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

Both rejection handlers unconditionally run process.exitCode = 1 (loopDone.catch at ~L881, and the symmetric promptResult.catch at ~L935). When a SIGINT/SIGTERM fires, the signal handler sets process.exitCode = 130/143 and calls abort() -> sdk.session.abort -> SessionPrompt.cancel(sessionID). If that cancellation propagates as a rejection of loop() or promptResult (a normal way for cancellation to surface), the .catch runs AFTER the handler already set 130/143 and clobbers it back to 1. The early-return path if (control.current) { await loopDone; await flush(); return } then returns, the using control dispose clears the grace timer so expire never fires, and the process exits with code 1 -- not the conventional 130/143 that is this PR's stated contract. The new integration test does NOT catch this: the mock /abort returns Response.json(true) + an idle session.status, so loop() resolves cleanly; any real SDK abort that surfaces as a rejection exposes the bug. There is no control.current guard in either catch. Secondary noise: console.error(e) in the same handlers would also dump the raw cancellation error to stderr after an intentional Ctrl+C.

          complete(error ?? classified.message)
          console.error(e)
          process.exitCode = 1
        })

      if (control.current) {
        await loopDone
        await flush()
        return
      }

Comment thread EVENTS.md
@@ -104,8 +104,8 @@ Emitted immediately before `session_complete` when the session terminates abnorm
}

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 enum values added to required field without version bump.

Suggested change
}
Either bump schemaVersion to "2" (additive but signals re-validation), or add an explicit CHANGELOG note that reason/code enum sets are open within v1 so pinned consumers have a clear re-check trigger.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, EVENTS.md:104-106):

Problem: New enum values added to required field without version bump
Detail: The PR adds two new values (`interrupted`, `terminated`) to the REQUIRED `session_error.reason` field and broadens the `code` field's semantics, yet `session_start.schemaVersion` stays `"1"`. The pre-PR v1 contract told consumers to treat unknown FIELDS as forward-compatible; this PR retroactively extends that same version's contract to also cover unknown event types and enum-like string values (line 10). A consumer that pinned to schema `"1"` under the original fields-only note and exhaustively validates `reason` against the previously-closed enum receives no version signal to re-audit their parser -- the contract changed under the same number. For a REQUIRED field with a previously-closed enum this is borderline-breaking for strict validators, even though the note update is the intended mitigation.
Suggested fix: Either bump schemaVersion to "2" (additive but signals re-validation), or add an explicit CHANGELOG note that reason/code enum sets are open within v1 so pinned consumers have a clear re-check trigger.

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 PR adds two new values (interrupted, terminated) to the REQUIRED session_error.reason field and broadens the code field's semantics, yet session_start.schemaVersion stays "1". The pre-PR v1 contract told consumers to treat unknown FIELDS as forward-compatible; this PR retroactively extends that same version's contract to also cover unknown event types and enum-like string values (line 10). A consumer that pinned to schema "1" under the original fields-only note and exhaustively validates reason against the previously-closed enum receives no version signal to re-audit their parser -- the contract changed under the same number. For a REQUIRED field with a previously-closed enum this is borderline-breaking for strict validators, even though the note update is the intended mitigation.

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
parts: [...files, { type: "text", text: message }],
})
}
if (control.current) abort()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 abort() may fire twice (signal handler + post-prompt).

Suggested change
if (control.current) abort()
Guard with a closed-over flag: `let aborted = false; function abort() { if (aborted) return; aborted = true; cancel(() => sdk.session.abort({ sessionID }), () => Log.Default.error("session abort failed")); }`.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:909):

Problem: abort() may fire twice (signal handler + post-prompt)
Detail: `abort()` (which calls `cancel(() => sdk.session.abort({ sessionID }), ...)`) is invoked from the signal `stop` callback the moment a signal arrives, AND again at `if (control.current) abort()` immediately after `sdk.session.prompt`/message is dispatched (L909). When a signal lands while a prompt is in flight, `sdk.session.abort` / `SessionPrompt.cancel` is therefore invoked twice. Correctness depends entirely on `SessionPrompt.cancel` being idempotent; if it rejects on the second invocation, the rejection is swallowed by the `fail` handler that only logs 'session abort failed', leaving an ambiguous abort state that can race with the loop's catch path. The `sessionErrorEmitted` guard protects the event, but the abort side-effect is unguarded.
Suggested fix: Guard with a closed-over flag: `let aborted = false; function abort() { if (aborted) return; aborted = true; cancel(() => sdk.session.abort({ sessionID }), () => Log.Default.error("session abort failed")); }`.

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

abort() (which calls cancel(() => sdk.session.abort({ sessionID }), ...)) is invoked from the signal stop callback the moment a signal arrives, AND again at if (control.current) abort() immediately after sdk.session.prompt/message is dispatched (L909). When a signal lands while a prompt is in flight, sdk.session.abort / SessionPrompt.cancel is therefore invoked twice. Correctness depends entirely on SessionPrompt.cancel being idempotent; if it rejects on the second invocation, the rejection is swallowed by the fail handler that only logs 'session abort failed', leaving an ambiguous abort state that can race with the loop's catch path. The sessionErrorEmitted guard protects the event, but the abort side-effect is unguarded.

}
state.current = signal
process.exitCode = signal.code
state.timer = setTimeout(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 expire continuation can observe advanced state.current.

Suggested change
state.timer = setTimeout(() => {
Capture the triggering signal once at the top of `handle` (already done as local `signal`) and have run.ts read the captured value rather than re-reading `control.current` after awaits; add a brief comment in `signals.ts` documenting that `state.current` is write-once.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/signals.ts:47-58):

Problem: expire continuation can observe advanced state.current
Detail: In `signals.ts`, `handle` schedules a grace timer whose async continuation (`expire?.(signal)` then `process.exit`) reads shared `state` (e.g. `state.hard`) after microtask/await boundaries. The re-entrancy guard handles a *second signal* by force-exiting synchronously, and `state.current` is write-once in practice (the only writer is `handle`, and a second call exits before mutating). However, the "first signal wins" invariant is only enforced by control flow, not by freezing the triggering signal as the source of truth for the post-await reads in run.ts (`error ?? signal.message`, `control.current`). Any future change that adds a second write path to `state.current` (or yields inside `stop` before the timer fires) would silently let the expire continuation act on a signal different from the one that started the sequence. Document/assert the write-once assumption, or capture the triggering signal once and key all continuations off it.
Suggested fix: Capture the triggering signal once at the top of `handle` (already done as local `signal`) and have run.ts read the captured value rather than re-reading `control.current` after awaits; add a brief comment in `signals.ts` documenting that `state.current` is write-once.

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

In signals.ts, handle schedules a grace timer whose async continuation (expire?.(signal) then process.exit) reads shared state (e.g. state.hard) after microtask/await boundaries. The re-entrancy guard handles a second signal by force-exiting synchronously, and state.current is write-once in practice (the only writer is handle, and a second call exits before mutating). However, the "first signal wins" invariant is only enforced by control flow, not by freezing the triggering signal as the source of truth for the post-await reads in run.ts (error ?? signal.message, control.current). Any future change that adds a second write path to state.current (or yields inside stop before the timer fires) would silently let the expire continuation act on a signal different from the one that started the sequence. Document/assert the write-once assumption, or capture the triggering signal once and key all continuations off it.

Comment thread packages/cli/src/cli/signals.ts Outdated
},
} as const satisfies Record<Signals.Info["name"], Signals.Info>

export function cancel(run: () => unknown, fail: () => void) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

`cancel` export name collides with SessionPrompt.cancel.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/signals.ts:25-27):

Problem: `cancel` export name collides with SessionPrompt.cancel
Detail: `export function cancel(run, fail)` in signals.ts is a generic async-runner-with-error-swallowing wrapper, not a cancellation primitive -- the actual cancellation is performed by the `run` callback (which calls `sdk.session.abort` -> `SessionPrompt.cancel`). The sibling `SessionPrompt.cancel` already exists with a different meaning. In run.ts both are in scope (`import { cancel } from "../signals"` alongside `sdk.session.abort` / `SessionPrompt.cancel`), so a reader seeing a bare `cancel(...)` call must check the import to disambiguate. A self-descriptive name would avoid the semantic clash.
Suggested fix: Rename to a self-descriptive name such as `runSwallowingErrors`, `safeRun`, or `attempt`, and update the call site in run.ts.

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

export function cancel(run, fail) in signals.ts is a generic async-runner-with-error-swallowing wrapper, not a cancellation primitive -- the actual cancellation is performed by the run callback (which calls sdk.session.abort -> SessionPrompt.cancel). The sibling SessionPrompt.cancel already exists with a different meaning. In run.ts both are in scope (import { cancel } from "../signals" alongside sdk.session.abort / SessionPrompt.cancel), so a reader seeing a bare cancel(...) call must check the import to disambiguate. A self-descriptive name would avoid the semantic clash.

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 2 · 🟡 3 · ⚪ 1 · 0/6 resolved

  • 🟡 EVENTS.md:104-106 — New enum values added to required field without version bump
  • 🟠 packages/cli/src/cli/cmd/run.ts:471-475 — flush() awaits a write callback that hides stdout errors
  • 🟠 packages/cli/src/cli/cmd/run.ts:881-882 — Signal exit code overwritten to 1 if abort rejects
  • 🟡 packages/cli/src/cli/cmd/run.ts:909 — abort() may fire twice (signal handler + post-prompt)
  • packages/cli/src/cli/signals.ts:25-27 — `cancel` export name collides with SessionPrompt.cancel
  • 🟡 packages/cli/src/cli/signals.ts:47-58 — expire continuation can observe advanced state.current
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #98 (head branch).
Run the relevant tests/linters after each change.

1. EVENTS.md:104-106 — New enum values added to required field without version bump
   Detail: The PR adds two new values (`interrupted`, `terminated`) to the REQUIRED `session_error.reason` field and broadens the `code` field's semantics, yet `session_start.schemaVersion` stays `"1"`. The pre-PR v1 contract told consumers to treat unknown FIELDS as forward-compatible; this PR retroactively extends that same version's contract to also cover unknown event types and enum-like string values (line 10). A consumer that pinned to schema `"1"` under the original fields-only note and exhaustively validates `reason` against the previously-closed enum receives no version signal to re-audit their parser -- the contract changed under the same number. For a REQUIRED field with a previously-closed enum this is borderline-breaking for strict validators, even though the note update is the intended mitigation.
   Suggested fix: Either bump schemaVersion to "2" (additive but signals re-validation), or add an explicit CHANGELOG note that reason/code enum sets are open within v1 so pinned consumers have a clear re-check trigger.
2. packages/cli/src/cli/cmd/run.ts:471-475 — flush() awaits a write callback that hides stdout errors
   Detail: The new `emit` wraps `process.stdout.write` in a promise resolved by the write callback, and the signal expire path does `await flush()` (returning `output`) before `process.exit(signal.code)`. The `writable.write` callback only signals that the chunk was handed to the kernel -- it does NOT propagate write errors (EPIPE / EIO), which surface via stdout's 'error' event, for which no listener is registered in this scope. When the consumer closes the pipe early (`aictrl run --format json | head`, parent crash, closed PTY), `await flush()` resolves "successfully" even though the `session_error`/`session_complete` lines were never delivered. The process then exits 130/143 reporting a graceful cancellation that downstream orchestrators never observed, defeating the purpose of the structured event. Additionally, reassigning `output = new Promise(...)` per emit (instead of chaining) means a rejection from an earlier write is dropped rather than propagated to `await flush()`. If `JSON.stringify` or `write` ever throws synchronously inside the executor (destroyed stream), `output` becomes a rejected promise that nothing `.catch`es synchronously, raising an unhandledRejection during signal handling.
   Suggested fix: Chain promises instead of reassigning (`output = output.then(() => writeChunk(...))`) so any rejection propagates to `await flush()` and into the expire onRejected branch; and register `process.stdout.on('error', (e) => Log.Default.error('stdout write failed'))` once at module load so EPIPE does not crash or silently lose the terminal events.
3. packages/cli/src/cli/cmd/run.ts:881-882 — Signal exit code overwritten to 1 if abort rejects
   Detail: Both rejection handlers unconditionally run `process.exitCode = 1` (loopDone.catch at ~L881, and the symmetric promptResult.catch at ~L935). When a SIGINT/SIGTERM fires, the signal handler sets `process.exitCode = 130/143` and calls `abort()` -> `sdk.session.abort` -> `SessionPrompt.cancel(sessionID)`. If that cancellation propagates as a rejection of `loop()` or `promptResult` (a normal way for cancellation to surface), the `.catch` runs AFTER the handler already set 130/143 and clobbers it back to 1. The early-return path `if (control.current) { await loopDone; await flush(); return }` then returns, the `using control` dispose clears the grace timer so `expire` never fires, and the process exits with code 1 -- not the conventional 130/143 that is this PR's stated contract. The new integration test does NOT catch this: the mock `/abort` returns `Response.json(true)` + an idle `session.status`, so `loop()` resolves cleanly; any real SDK abort that surfaces as a rejection exposes the bug. There is no `control.current` guard in either catch. Secondary noise: `console.error(e)` in the same handlers would also dump the raw cancellation error to stderr after an intentional Ctrl+C.
   Suggested fix: Gate the fallback on the absence of a signal in BOTH catch handlers: `if (!control.current) process.exitCode = 1` (and wrap `console.error(e)` in the same guard) so an intentional cancellation does not overwrite the 130/143 exit code or print a misleading stack.
4. packages/cli/src/cli/cmd/run.ts:909 — abort() may fire twice (signal handler + post-prompt)
   Detail: `abort()` (which calls `cancel(() => sdk.session.abort({ sessionID }), ...)`) is invoked from the signal `stop` callback the moment a signal arrives, AND again at `if (control.current) abort()` immediately after `sdk.session.prompt`/message is dispatched (L909). When a signal lands while a prompt is in flight, `sdk.session.abort` / `SessionPrompt.cancel` is therefore invoked twice. Correctness depends entirely on `SessionPrompt.cancel` being idempotent; if it rejects on the second invocation, the rejection is swallowed by the `fail` handler that only logs 'session abort failed', leaving an ambiguous abort state that can race with the loop's catch path. The `sessionErrorEmitted` guard protects the event, but the abort side-effect is unguarded.
   Suggested fix: Guard with a closed-over flag: `let aborted = false; function abort() { if (aborted) return; aborted = true; cancel(() => sdk.session.abort({ sessionID }), () => Log.Default.error("session abort failed")); }`.
5. packages/cli/src/cli/signals.ts:25-27 — `cancel` export name collides with SessionPrompt.cancel
   Detail: `export function cancel(run, fail)` in signals.ts is a generic async-runner-with-error-swallowing wrapper, not a cancellation primitive -- the actual cancellation is performed by the `run` callback (which calls `sdk.session.abort` -> `SessionPrompt.cancel`). The sibling `SessionPrompt.cancel` already exists with a different meaning. In run.ts both are in scope (`import { cancel } from "../signals"` alongside `sdk.session.abort` / `SessionPrompt.cancel`), so a reader seeing a bare `cancel(...)` call must check the import to disambiguate. A self-descriptive name would avoid the semantic clash.
   Suggested fix: Rename to a self-descriptive name such as `runSwallowingErrors`, `safeRun`, or `attempt`, and update the call site in run.ts.
6. packages/cli/src/cli/signals.ts:47-58 — expire continuation can observe advanced state.current
   Detail: In `signals.ts`, `handle` schedules a grace timer whose async continuation (`expire?.(signal)` then `process.exit`) reads shared `state` (e.g. `state.hard`) after microtask/await boundaries. The re-entrancy guard handles a *second signal* by force-exiting synchronously, and `state.current` is write-once in practice (the only writer is `handle`, and a second call exits before mutating). However, the "first signal wins" invariant is only enforced by control flow, not by freezing the triggering signal as the source of truth for the post-await reads in run.ts (`error ?? signal.message`, `control.current`). Any future change that adds a second write path to `state.current` (or yields inside `stop` before the timer fires) would silently let the expire continuation act on a signal different from the one that started the sequence. Document/assert the write-once assumption, or capture the triggering signal once and key all continuations off it.
   Suggested fix: Capture the triggering signal once at the top of `handle` (already done as local `signal`) and have run.ts read the captured value rather than re-reading `control.current` after awaits; add a brief comment in `signals.ts` documenting that `state.current` is write-once.
📋 Out-of-diff findings (6)
Sev Location Finding
🟡 EVENTS.md:104-106 New enum values added to required field without version bump
🟠 packages/cli/src/cli/cmd/run.ts:471-475 flush() awaits a write callback that hides stdout errors
🟠 packages/cli/src/cli/cmd/run.ts:881-882 Signal exit code overwritten to 1 if abort rejects
🟡 packages/cli/src/cli/cmd/run.ts:909 abort() may fire twice (signal handler + post-prompt)
packages/cli/src/cli/signals.ts:25-27 `cancel` export name collides with SessionPrompt.cancel
🟡 packages/cli/src/cli/signals.ts:47-58 expire continuation can observe advanced state.current

Reviewed 7 files · 0 inline · view all 6 findings ↗


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

@byapparov

Copy link
Copy Markdown
Contributor Author

Final bounded review response

Judged against round-two head 8b7107d50; remediated at 0ec7d0fbe. This is the final review round; no additional automated re-review is requested.

Finding Verdict / action Resolution Verification
v1 enum compatibility TRUE · FIX Added an explicit CHANGELOG.md compatibility note: v1 terminal reason/code strings are open and now include interrupted/terminated plus 130/143. A v2 bump would unnecessarily break the existing additive contract. documentation audit + workspace typecheck PASS
stdout write errors TRUE · FIX The signal-local barrier now tracks every pending real write, observes callback and scoped stdout errors, treats EPIPE as a controlled closed consumer, and throws only a fixed safe error for other failures. A comment explicitly leaves global writer ownership to #91/PR #95 for merge composition. closed-consumer subprocess test + focused suite PASS
signal code clobber TRUE · FIX All three primary failure paths now set exit 1 / print raw errors only when no signal is active, preserving 130/143 for cancellation rejection or error events. signal-code guard regression + SIGINT/SIGTERM subprocess tests PASS
duplicate abort TRUE · FIX abort() is idempotent; a held in-flight prompt proves the signal callback and post-prompt check produce one backend abort request. in-flight prompt subprocess test PASS
cancel helper naming TRUE · FIX Renamed the generic promise wrapper from cancel to attempt, removing the live collision with SessionPrompt.cancel. typecheck + sync/async containment tests PASS
advanced state.current FALSE · IGNORE Current behavior already captures the triggering signal in a local constant; state.current is write-once and a second signal synchronously exits before mutation. The run expiry callback uses its captured signal and no longer reads control.current while flushing. The concern requires a hypothetical future writer and is not a current defect. exact-head control-flow audit

Verification

  • Focused CLI suite: 28 passed, 0 failed
  • Full packages/cli suite: 1,336 passed, 7 skipped, 0 failed
  • bun run typecheck in packages/cli: PASS
  • bun turbo typecheck: 6/6 tasks successful
  • git diff --check: PASS
  • Worktree clean after commit/push

No merge performed and no further review loop requested.

@byapparov
byapparov force-pushed the fix/issue-94-signal-cancellation branch from 0ec7d0f to e97241e Compare July 20, 2026 18:36
error: message,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 report() can emit session_error after session_complete.

--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -546,7 +546,7 @@
       function report(reason: string, code: string | undefined, message: string) {
-        if (sessionErrorEmitted) return
+        if (sessionCompleteEmitted || sessionErrorEmitted) return
         sessionErrorEmitted = true
         emit("session_error", { reason, code, message })
       }
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:546-550):

Problem: report() can emit session_error after session_complete
Detail: `report()` only guards on `sessionErrorEmitted`, never on `sessionCompleteEmitted`. In the normal (non-signal) path, `loopDone = loop().then(() => complete(), fail)` runs `complete()` (emitting `session_complete`) BEFORE the trailing `await Shutdown.flush()`. The SIGINT/SIGTERM listeners installed by `using control = signals(...)` stay armed until scope exit (dispose runs on function return), so a signal arriving during that final flush invokes `interrupt()` -> `report()`, which emits `session_error` AFTER `session_complete`. This (1) violates the documented v1 invariant that `session_error` precedes `session_complete` (asserted by run-schema-v1.test.ts and promised by EVENTS.md) and (2) emits an error event for a session that already completed successfully. `complete()` was given the symmetric idempotency guard; `report()` should have the same. The same window exists on the `if (control.current)` early-return path's `await Shutdown.flush()` and the post-`Promise.race` flush.
Suggested fix: Make `report()` terminal-aware so it cannot fire after a normal completion: bail when the session already completed. Symmetrically, `interrupt()` could early-return when `sessionCompleteEmitted` is already true.

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -546,7 +546,7 @@
       function report(reason: string, code: string | undefined, message: string) {
-        if (sessionErrorEmitted) return
+        if (sessionCompleteEmitted || sessionErrorEmitted) return
         sessionErrorEmitted = true
         emit("session_error", { reason, code, 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

report() only guards on sessionErrorEmitted, never on sessionCompleteEmitted. In the normal (non-signal) path, loopDone = loop().then(() => complete(), fail) runs complete() (emitting session_complete) BEFORE the trailing await Shutdown.flush(). The SIGINT/SIGTERM listeners installed by using control = signals(...) stay armed until scope exit (dispose runs on function return), so a signal arriving during that final flush invokes interrupt() -> report(), which emits session_error AFTER session_complete. This (1) violates the documented v1 invariant that session_error precedes session_complete (asserted by run-schema-v1.test.ts and promised by EVENTS.md) and (2) emits an error event for a session that already completed successfully. complete() was given the symmetric idempotency guard; report() should have the same. The same window exists on the if (control.current) early-return path's await Shutdown.flush() and the post-Promise.race flush.

      function complete(message = error ?? null) {
        if (sessionCompleteEmitted) return
        sessionCompleteEmitted = true
        emit("session_complete", {
          durationMs: Date.now() - startTime,
          error: message,
        })
      }

      function report(reason: string, code: string | undefined, message: string) {
        if (sessionErrorEmitted) return
        sessionErrorEmitted = true
        emit("session_error", { reason, code, message })
      }

@@ -961,6 +963,9 @@ export const RunCommand = cmd({
const share = await Session.share(opts.sessionID)
return { data: { share } }
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 abort RPC handler forwards unvalidated opts.sessionID.

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

Problem: abort RPC handler forwards unvalidated opts.sessionID
Detail: The new `abort(opts: any)` handler forwards `opts.sessionID` straight to `SessionPrompt.cancel(opts.sessionID)` with no type guard or ownership check, and `opts` is typed `any`. `abort` is destructive (cancels the active prompt), so a reachable caller supplying an arbitrary session identifier could cancel a session it did not initiate. This mirrors the pre-existing unvalidated-`opts` pattern of the sibling `prompt`/`share` handlers and is almost certainly local-only dispatch today (the CLI invoking its own handlers), so the practical risk is low — but cancellation is a stronger primitive than read/share, so as defense-in-depth the handler should validate its input. If the attach endpoint is ever exposed to less-trusted callers, this becomes a cross-session DoS surface.
Suggested fix: Type the input explicitly (e.g. `abort(opts: { sessionID: string })`) and validate it before dispatching: minimal guard `if (typeof opts?.sessionID !== \"string\" || !opts.sessionID) throw new Error(\"sessionID required\")`. Preferably apply the same typing to the sibling `prompt`/`share` handlers.

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 abort(opts: any) handler forwards opts.sessionID straight to SessionPrompt.cancel(opts.sessionID) with no type guard or ownership check, and opts is typed any. abort is destructive (cancels the active prompt), so a reachable caller supplying an arbitrary session identifier could cancel a session it did not initiate. This mirrors the pre-existing unvalidated-opts pattern of the sibling prompt/share handlers and is almost certainly local-only dispatch today (the CLI invoking its own handlers), so the practical risk is low — but cancellation is a stronger primitive than read/share, so as defense-in-depth the handler should validate its input. If the attach endpoint is ever exposed to less-trusted callers, this becomes a cross-session DoS surface.

            share(opts: any) {
              const share = await Session.share(opts.sessionID)
              return { data: { share } }
            },
          abort(opts: any) {
            return SessionPrompt.cancel(opts.sessionID)
          },
          async prompt(opts: any) {

}

using control = signals(interrupt, 5_000, expire)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 fail() drops real cause when a signal arrived first.

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

Problem: fail() drops real cause when a signal arrived first
Detail: If a signal lands first, `interrupt()` sets `error = signal.message` and emits `session_error`. When the loop later rejects and `fail(cause)` runs: `report(...)` is a no-op (`sessionErrorEmitted` already true), `complete(error)` records `signal.message`, and `if (control.current) return` (line 865) skips `console.error(cause)`. The actual rejection cause therefore appears in none of `session_error`, `session_complete`, or stderr. A genuine non-signal failure that coincides with (or surfaces just after) an operator interrupt is fully masked as a clean "interrupted" run, which hurts headless CI debuggability where stderr/NDJSON is the only diagnostic channel.
Suggested fix: When `control.current` is set, still surface the cause before returning, e.g. `Log.Default.error("run failed after signal", { error: cause })`, so it is not dropped entirely.

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

If a signal lands first, interrupt() sets error = signal.message and emits session_error. When the loop later rejects and fail(cause) runs: report(...) is a no-op (sessionErrorEmitted already true), complete(error) records signal.message, and if (control.current) return (line 865) skips console.error(cause). The actual rejection cause therefore appears in none of session_error, session_complete, or stderr. A genuine non-signal failure that coincides with (or surfaces just after) an operator interrupt is fully masked as a clean "interrupted" run, which hurts headless CI debuggability where stderr/NDJSON is the only diagnostic channel.

      function fail(cause: unknown) {
        const classified = classifySessionError(cause)
        error ??= classified.message
        report(classified.reason, classified.code, classified.message)
        complete(error)
        if (control.current) return
        console.error(cause)
        process.exitCode = 1
      }

complete(error ?? signal.message)
await Shutdown.flush()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 abort() no-op if signal beats RPC handler wiring.

Suggested change
Register the `abort` RPC handler (and ensure `sdk.session.abort` is routable) before `signals(...)` arms its listeners, or defer arming the signal listeners until the attach session object is fully constructed.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/src/cli/cmd/run.ts:858):

Problem: abort() no-op if signal beats RPC handler wiring
Detail: `using control = signals(interrupt, 5_000, expire)` arms the SIGINT/SIGTERM listeners at line 858, but the `abort(opts)` RPC handler (and the `sdk` object it dispatches through) is registered later in the function. A signal delivered during that setup window calls `interrupt()` -> `abort()` -> `sdk.session.abort({ sessionID })` before the handler is routable, so the RPC is unmatched/rejects and `attempt()` swallows it via `Log.Default.error("session abort failed")`. The in-flight session is then never gracefully cancelled and only gets force-exited at the 5s grace deadline. Stream shape (session_error/session_complete) and exit code are still correct due to idempotency + the grace timer, but the documented "abort the in-flight session" contract is silently skipped in this window, so any session-side cleanup hooks do not run. Depends on the exact registration order of `sdk`/the attach session object relative to `signals(...)`.
Suggested fix: Register the `abort` RPC handler (and ensure `sdk.session.abort` is routable) before `signals(...)` arms its listeners, or defer arming the signal listeners until the attach session object is fully constructed.

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

using control = signals(interrupt, 5_000, expire) arms the SIGINT/SIGTERM listeners at line 858, but the abort(opts) RPC handler (and the sdk object it dispatches through) is registered later in the function. A signal delivered during that setup window calls interrupt() -> abort() -> sdk.session.abort({ sessionID }) before the handler is routable, so the RPC is unmatched/rejects and attempt() swallows it via Log.Default.error("session abort failed"). The in-flight session is then never gracefully cancelled and only gets force-exited at the 5s grace deadline. Stream shape (session_error/session_complete) and exit code are still correct due to idempotency + the grace timer, but the documented "abort the in-flight session" contract is silently skipped in this window, so any session-side cleanup hooks do not run. Depends on the exact registration order of sdk/the attach session object relative to signals(...).

      function abort() {
        if (aborted) return
        aborted = true
        attempt(
          () => sdk.session.abort({ sessionID }),
          () => Log.Default.error("session abort failed"),
        )
      }

}
if (!control.current) process.exitCode = 1
const classified = classifySessionError(props.error)
// Structured session_error is emitted for the primary session only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

session_error vs error channel rationale lost.

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

Problem: session_error vs error channel rationale lost
Detail: The loop's session.error handler previously carried a multi-line comment explaining WHY `session_error` and the legacy `error` event are intentionally distinct channels ("session_error is for structured telemetry/CI consumers, 'error' is the legacy observable for raw error pass-through"). The replacement comment keeps the "primary-only vs both" fact but drops the audience/rationale, which is exactly the context future editors need to avoid collapsing the two channels. The rest of the file preserves rationale-style comments (e.g. the `process.exitCode` block immediately above), so this is inconsistent with the surrounding comment density.
Suggested fix: Restore the audience note, e.g. `// Structured session_error is emitted for the primary session only — it is the telemetry/CI channel. The legacy "error" event below is the raw pass-through for both primary and child-session failures.`

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 loop's session.error handler previously carried a multi-line comment explaining WHY session_error and the legacy error event are intentionally distinct channels ("session_error is for structured telemetry/CI consumers, 'error' is the legacy observable for raw error pass-through"). The replacement comment keeps the "primary-only vs both" fact but drops the audience/rationale, which is exactly the context future editors need to avoid collapsing the two channels. The rest of the file preserves rationale-style comments (e.g. the process.exitCode block immediately above), so this is inconsistent with the surrounding comment density.

              if (!control.current) process.exitCode = 1
              const classified = classifySessionError(props.error)
              // Structured session_error is emitted for the primary session only.
              // The legacy "error" event below remains the raw pass-through for
              // both primary and child-session failures.
              report(classified.reason, classified.code, classified.message)

const errIdx = source.indexOf('emit("session_error"')
expect(errIdx).toBeGreaterThan(-1)
expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"'))
expect(source.slice(errIdx).indexOf("complete(")).toBeGreaterThan(-1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

v1 schema ordering assertion was weakened by the refactor.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #98, packages/cli/test/cli/run-schema-v1.test.ts:42-43):

Problem: v1 schema ordering assertion was weakened by the refactor
Detail: The old assertion `expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"'))` enforced that the first `emit("session_error")` precedes the last `emit("session_complete")` in source — a structural proxy for the runtime ordering guarantee. The replacement `expect(source.slice(errIdx).indexOf("complete(")).toBeGreaterThan(-1)` only checks that some `complete(` call textually follows the session_error emit site, which is trivially true because `fail()` calls `complete(error)` after `report(...)`. The new assertion no longer protects against regressions where `session_complete` is emitted before `session_error` at the source level; that invariant is now covered only by the heavier integration tests in run-signal-cancellation.test.ts. Consider keeping a stricter source-ordering check alongside the new idempotency check.
Suggested fix: Also assert relative ordering of the emit sites, e.g. locate `emit("session_error"` inside `report()` and `emit("session_complete"` inside `complete()` and assert error-index < complete-index, rather than just that `complete(` appears later in the file.

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 old assertion expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"')) enforced that the first emit("session_error") precedes the last emit("session_complete") in source — a structural proxy for the runtime ordering guarantee. The replacement expect(source.slice(errIdx).indexOf("complete(")).toBeGreaterThan(-1) only checks that some complete( call textually follows the session_error emit site, which is trivially true because fail() calls complete(error) after report(...). The new assertion no longer protects against regressions where session_complete is emitted before session_error at the source level; that invariant is now covered only by the heavier integration tests in run-signal-cancellation.test.ts. Consider keeping a stricter source-ordering check alongside the new idempotency check.

    const source = await Bun.file(RUN_SRC).text()
    const errIdx = source.indexOf('emit("session_error"')
    expect(errIdx).toBeGreaterThan(-1)
    expect(source.slice(errIdx).indexOf("complete(")).toBeGreaterThan(-1)
    expect(source).toContain("if (sessionCompleteEmitted) return")

@aictrl-dev

aictrl-dev Bot commented Jul 20, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 3 · ⚪ 2 · 0/6 resolved

  • 🟠 packages/cli/src/cli/cmd/run.ts:546-550 — report() can emit session_error after session_complete
  • packages/cli/src/cli/cmd/run.ts:701-703 — session_error vs error channel rationale lost
  • 🟡 packages/cli/src/cli/cmd/run.ts:858 — abort() no-op if signal beats RPC handler wiring
  • 🟡 packages/cli/src/cli/cmd/run.ts:860-868 — fail() drops real cause when a signal arrived first
  • 🟡 packages/cli/src/cli/cmd/run.ts:965-967 — abort RPC handler forwards unvalidated opts.sessionID
  • packages/cli/test/cli/run-schema-v1.test.ts:42-43 — v1 schema ordering assertion was weakened by the refactor
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #98 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:546-550 — report() can emit session_error after session_complete
   Detail: `report()` only guards on `sessionErrorEmitted`, never on `sessionCompleteEmitted`. In the normal (non-signal) path, `loopDone = loop().then(() => complete(), fail)` runs `complete()` (emitting `session_complete`) BEFORE the trailing `await Shutdown.flush()`. The SIGINT/SIGTERM listeners installed by `using control = signals(...)` stay armed until scope exit (dispose runs on function return), so a signal arriving during that final flush invokes `interrupt()` -> `report()`, which emits `session_error` AFTER `session_complete`. This (1) violates the documented v1 invariant that `session_error` precedes `session_complete` (asserted by run-schema-v1.test.ts and promised by EVENTS.md) and (2) emits an error event for a session that already completed successfully. `complete()` was given the symmetric idempotency guard; `report()` should have the same. The same window exists on the `if (control.current)` early-return path's `await Shutdown.flush()` and the post-`Promise.race` flush.
   Suggested fix: Make `report()` terminal-aware so it cannot fire after a normal completion: bail when the session already completed. Symmetrically, `interrupt()` could early-return when `sessionCompleteEmitted` is already true.
2. packages/cli/src/cli/cmd/run.ts:701-703 — session_error vs error channel rationale lost
   Detail: The loop's session.error handler previously carried a multi-line comment explaining WHY `session_error` and the legacy `error` event are intentionally distinct channels ("session_error is for structured telemetry/CI consumers, 'error' is the legacy observable for raw error pass-through"). The replacement comment keeps the "primary-only vs both" fact but drops the audience/rationale, which is exactly the context future editors need to avoid collapsing the two channels. The rest of the file preserves rationale-style comments (e.g. the `process.exitCode` block immediately above), so this is inconsistent with the surrounding comment density.
   Suggested fix: Restore the audience note, e.g. `// Structured session_error is emitted for the primary session only — it is the telemetry/CI channel. The legacy "error" event below is the raw pass-through for both primary and child-session failures.`
3. packages/cli/src/cli/cmd/run.ts:858 — abort() no-op if signal beats RPC handler wiring
   Detail: `using control = signals(interrupt, 5_000, expire)` arms the SIGINT/SIGTERM listeners at line 858, but the `abort(opts)` RPC handler (and the `sdk` object it dispatches through) is registered later in the function. A signal delivered during that setup window calls `interrupt()` -> `abort()` -> `sdk.session.abort({ sessionID })` before the handler is routable, so the RPC is unmatched/rejects and `attempt()` swallows it via `Log.Default.error("session abort failed")`. The in-flight session is then never gracefully cancelled and only gets force-exited at the 5s grace deadline. Stream shape (session_error/session_complete) and exit code are still correct due to idempotency + the grace timer, but the documented "abort the in-flight session" contract is silently skipped in this window, so any session-side cleanup hooks do not run. Depends on the exact registration order of `sdk`/the attach session object relative to `signals(...)`.
   Suggested fix: Register the `abort` RPC handler (and ensure `sdk.session.abort` is routable) before `signals(...)` arms its listeners, or defer arming the signal listeners until the attach session object is fully constructed.
4. packages/cli/src/cli/cmd/run.ts:860-868 — fail() drops real cause when a signal arrived first
   Detail: If a signal lands first, `interrupt()` sets `error = signal.message` and emits `session_error`. When the loop later rejects and `fail(cause)` runs: `report(...)` is a no-op (`sessionErrorEmitted` already true), `complete(error)` records `signal.message`, and `if (control.current) return` (line 865) skips `console.error(cause)`. The actual rejection cause therefore appears in none of `session_error`, `session_complete`, or stderr. A genuine non-signal failure that coincides with (or surfaces just after) an operator interrupt is fully masked as a clean "interrupted" run, which hurts headless CI debuggability where stderr/NDJSON is the only diagnostic channel.
   Suggested fix: When `control.current` is set, still surface the cause before returning, e.g. `Log.Default.error("run failed after signal", { error: cause })`, so it is not dropped entirely.
5. packages/cli/src/cli/cmd/run.ts:965-967 — abort RPC handler forwards unvalidated opts.sessionID
   Detail: The new `abort(opts: any)` handler forwards `opts.sessionID` straight to `SessionPrompt.cancel(opts.sessionID)` with no type guard or ownership check, and `opts` is typed `any`. `abort` is destructive (cancels the active prompt), so a reachable caller supplying an arbitrary session identifier could cancel a session it did not initiate. This mirrors the pre-existing unvalidated-`opts` pattern of the sibling `prompt`/`share` handlers and is almost certainly local-only dispatch today (the CLI invoking its own handlers), so the practical risk is low — but cancellation is a stronger primitive than read/share, so as defense-in-depth the handler should validate its input. If the attach endpoint is ever exposed to less-trusted callers, this becomes a cross-session DoS surface.
   Suggested fix: Type the input explicitly (e.g. `abort(opts: { sessionID: string })`) and validate it before dispatching: minimal guard `if (typeof opts?.sessionID !== \"string\" || !opts.sessionID) throw new Error(\"sessionID required\")`. Preferably apply the same typing to the sibling `prompt`/`share` handlers.
6. packages/cli/test/cli/run-schema-v1.test.ts:42-43 — v1 schema ordering assertion was weakened by the refactor
   Detail: The old assertion `expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"'))` enforced that the first `emit("session_error")` precedes the last `emit("session_complete")` in source — a structural proxy for the runtime ordering guarantee. The replacement `expect(source.slice(errIdx).indexOf("complete(")).toBeGreaterThan(-1)` only checks that some `complete(` call textually follows the session_error emit site, which is trivially true because `fail()` calls `complete(error)` after `report(...)`. The new assertion no longer protects against regressions where `session_complete` is emitted before `session_error` at the source level; that invariant is now covered only by the heavier integration tests in run-signal-cancellation.test.ts. Consider keeping a stricter source-ordering check alongside the new idempotency check.
   Suggested fix: Also assert relative ordering of the emit sites, e.g. locate `emit("session_error"` inside `report()` and `emit("session_complete"` inside `complete()` and assert error-index < complete-index, rather than just that `complete(` appears later in the file.
📋 Out-of-diff findings (6)
Sev Location Finding
🟠 packages/cli/src/cli/cmd/run.ts:546-550 report() can emit session_error after session_complete
packages/cli/src/cli/cmd/run.ts:701-703 session_error vs error channel rationale lost
🟡 packages/cli/src/cli/cmd/run.ts:858 abort() no-op if signal beats RPC handler wiring
🟡 packages/cli/src/cli/cmd/run.ts:860-868 fail() drops real cause when a signal arrived first
🟡 packages/cli/src/cli/cmd/run.ts:965-967 abort RPC handler forwards unvalidated opts.sessionID
packages/cli/test/cli/run-schema-v1.test.ts:42-43 v1 schema ordering assertion was weakened by the refactor

Reviewed 11 files · 0 inline · view all 6 findings ↗


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

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.

Handle SIGTERM and SIGINT as graceful headless cancellation

1 participant