Skip to content

feat(cli): add NDJSON invocation lifecycle events#96

Open
byapparov wants to merge 4 commits into
mainfrom
feat/issue-90-invocation-lifecycle
Open

feat(cli): add NDJSON invocation lifecycle events#96
byapparov wants to merge 4 commits into
mainfrom
feat/issue-90-invocation-lifecycle

Conversation

@byapparov

@byapparov byapparov commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #90

Intent

Give every JSON-mode run a deterministic invocation envelope, including failures that happen before a real session exists.

Expected outcomes

  • Emit invocation_start before stdin reads, argument validation, or bootstrap work.
  • Emit stable, sanitized invocation_error events for pre-session failures.
  • Emit exactly one invocation_complete whose status agrees with the process outcome.
  • Link a real session when one is created without fabricating session identifiers.
  • Preserve lifecycle delivery through validation, parsing, late-error, and forced-exit paths.

Expected impact

Orchestrators can reliably detect that a run started and finished even when session creation never succeeds. Consumers gain stable failure classification and correlation while the schema-v1 change remains additive.

Summary

  • emit invocation_start before stdin, validation, and bootstrap for JSON runs
  • emit structured pre-session invocation_error events and exactly one invocation_complete
  • link real sessions through invocationID without fabricating session IDs
  • document the v1 invocation envelope and keep parser failures as clean NDJSON

Verification

  • bun test from packages/cli — 1332 passed, 7 skipped
  • bun test test/cli/invocation-lifecycle.test.ts test/cli/run-schema-v1.test.ts test/cli/run-session-error-emit.test.ts test/cli/run-event-race.test.ts test/cli/run-error-propagation.test.ts test/cli/exception-handler.test.ts — 24 passed
  • bun run typecheck from packages/cli
  • push hook: bun turbo typecheck — 6 tasks passed

Risk

The lifecycle starts only for explicit run --format json / run --format=json invocations. Output durability and forced-exit flushing remain scoped to #91.

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
handler: async (args) => {
Invocation.phase("validation")

function fail(message: string, code: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 fail() process.exit races stdout NDJSON write.

Suggested change
function fail(message: string, code: string) {
Route `fail()` through the same flush-then-exit helper used by the top-level `finally` (await stdout `'finish'`/drain before `process.exit`), instead of calling `process.exit(1)` synchronously after `Invocation.abort()`.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/cmd/run.ts:338-342):

Problem: fail() process.exit races stdout NDJSON write
Detail: The new `fail()` helper calls `Invocation.abort()` (which writes `invocation_error` + `invocation_complete` NDJSON via `process.stdout.write`) and then immediately calls `process.exit(1)`. On a non-TTY stdout (pipe/file/CI capture), writes are async-buffered and `process.exit()` does not await the drain, so the terminal NDJSON events can be truncated or lost. This reintroduces the race at every validation failure path (`INVOCATION_INVALID_DIRECTORY`, `INVOCATION_FILE_NOT_FOUND`, `INVOCATION_EMPTY_INPUT`, `INVOCATION_INVALID_ARGUMENTS`, `INVOCATION_SESSION_CREATE_FAILED`). If a flush-then-exit helper exists for the top-level finally path, `fail()` bypasses it. Confidence is moderate because the lifecycle test does capture all three events in piped-stdout scenarios; depending on runtime (Bun may flush synchronously for small writes) the race may not manifest in practice, but the code as written does not guarantee delivery.
Suggested fix: Route `fail()` through the same flush-then-exit helper used by the top-level `finally` (await stdout `'finish'`/drain before `process.exit`), instead of calling `process.exit(1)` synchronously after `Invocation.abort()`.

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 fail() helper calls Invocation.abort() (which writes invocation_error + invocation_complete NDJSON via process.stdout.write) and then immediately calls process.exit(1). On a non-TTY stdout (pipe/file/CI capture), writes are async-buffered and process.exit() does not await the drain, so the terminal NDJSON events can be truncated or lost. This reintroduces the race at every validation failure path (INVOCATION_INVALID_DIRECTORY, INVOCATION_FILE_NOT_FOUND, INVOCATION_EMPTY_INPUT, INVOCATION_INVALID_ARGUMENTS, INVOCATION_SESSION_CREATE_FAILED). If a flush-then-exit helper exists for the top-level finally path, fail() bypasses it. Confidence is moderate because the lifecycle test does capture all three events in piped-stdout scenarios; depending on runtime (Bun may flush synchronously for small writes) the race may not manifest in practice, but the code as written does not guarantee delivery.

    function fail(message: string, code: string) {
      Invocation.abort(message, code)
      UI.error(message)
      process.exit(1)
    }

Comment thread packages/cli/src/cli/invocation.ts Outdated
sessionID = id
},
error(error: unknown, code = "INVOCATION_FAILED") {
if (!invocationID || sessionID || failed || completed) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Post-session failures report status:completed.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:47-68):

Problem: Post-session failures report status:completed
Detail: `error()`'s guard `if (!invocationID || sessionID || failed || completed) return` causes it to silently no-op once `Invocation.link(sessionID)` runs in run.ts. Any later failure (share()/execute()/bootstrap-callback throws, POST /message 500, network drops, agent errors, or an unhandledRejection/uncaughtException arriving mid-session) propagates to the `catch` in headless.ts/index.ts, where `Invocation.error(e)` (and `Invocation.abort(e)` from the global handlers) early-returns because `sessionID` is truthy — so `failed` is never set. The `finally` block then calls `complete()` which emits `{ type: "invocation_complete", status: "completed", sessionID }` even though `process.exitCode = 1` was set in the catch. NDJSON consumers see a successful invocation while the shell sees a non-zero exit code. EVENTS.md hand-waves this with "Errors after a real session has been created use session_error instead", but nothing in this PR guarantees a `session_error` is emitted on every post-link failure path, and the `invocation_complete.status` field directly contradicts the actual outcome. The new lifecycle test only covers pre-link failures (`session()` returning null at L538); no test exercises a post-link failure, so the regression is unguarded.
Suggested fix: Decouple the `failed` flag from `invocation_error` emission. Either remove `|| sessionID` from the `error()` guard (so invocation_error is still emitted alongside any session_error), or add a `markFailed()` that sets `failed = true` without emitting when `sessionID` is set, and call it from the headless.ts/index.ts catch and from the unhandledRejection/uncaughtException handlers. At minimum, `complete()` must derive status from something a post-link error can affect — e.g. check `process.exitCode !== 0` or accept an explicit status override.

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

error()'s guard if (!invocationID || sessionID || failed || completed) return causes it to silently no-op once Invocation.link(sessionID) runs in run.ts. Any later failure (share()/execute()/bootstrap-callback throws, POST /message 500, network drops, agent errors, or an unhandledRejection/uncaughtException arriving mid-session) propagates to the catch in headless.ts/index.ts, where Invocation.error(e) (and Invocation.abort(e) from the global handlers) early-returns because sessionID is truthy — so failed is never set. The finally block then calls complete() which emits { type: "invocation_complete", status: "completed", sessionID } even though process.exitCode = 1 was set in the catch. NDJSON consumers see a successful invocation while the shell sees a non-zero exit code. EVENTS.md hand-waves this with "Errors after a real session has been created use session_error instead", but nothing in this PR guarantees a session_error is emitted on every post-link failure path, and the invocation_complete.status field directly contradicts the actual outcome. The new lifecycle test only covers pre-link failures (session() returning null at L538); no test exercises a post-link failure, so the regression is unguarded.

    error(error: unknown, code = "INVOCATION_FAILED") {
      if (!invocationID || sessionID || failed || completed) return
      failed = true
      emit("invocation_error", {
        phase,
        code,
        message:
          error instanceof Error
            ? error.message
            : error && typeof error === "object" && "message" in error
              ? String(error.message)
              : String(error),
      })
    },
    complete() {
      if (!invocationID || completed) return
      completed = true
      emit("invocation_complete", {
        status: failed ? "error" : "completed",
        durationMs: Date.now() - started,
        ...(sessionID ? { sessionID } : {}),
      })
    },

Comment thread packages/cli/src/cli/invocation.ts Outdated
emit("invocation_error", {
phase,
code,
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.

🟠 Raw error messages leaked to stdout NDJSON.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:52-58):

Problem: Raw error messages leaked to stdout NDJSON
Detail: `Invocation.error()`/`abort()` serialize `error.message` (or `String(error)`) verbatim into the stdout NDJSON stream for every unhandledRejection, uncaughtException, parse error, and run-handler failure. Error messages from network/SDK/auth/filesystem layers routinely carry absolute paths, internal URLs, auth tokens, API keys, or env-derived values. Stdout is the machine-readable channel that consumers pipe to log aggregators and CI capture, so this widens the disclosure surface beyond the pre-existing `Log.Default.error` path (which is stderr/file-scoped). The `String(error)` else branch additionally dumps arbitrary object graphs for non-Error rejections. This is reachable from every error path that the new lifecycle instrumentation wraps.
Suggested fix: Sanitize before emitting: cap length, redact URLs/tokens/paths, or emit only the stable `code` + `phase` and keep the raw message on the existing `Log` channel. At minimum drop the `String(error)` fallback and never serialize arbitrary objects.

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

Invocation.error()/abort() serialize error.message (or String(error)) verbatim into the stdout NDJSON stream for every unhandledRejection, uncaughtException, parse error, and run-handler failure. Error messages from network/SDK/auth/filesystem layers routinely carry absolute paths, internal URLs, auth tokens, API keys, or env-derived values. Stdout is the machine-readable channel that consumers pipe to log aggregators and CI capture, so this widens the disclosure surface beyond the pre-existing Log.Default.error path (which is stderr/file-scoped). The String(error) else branch additionally dumps arbitrary object graphs for non-Error rejections. This is reachable from every error path that the new lifecycle instrumentation wraps.

      emit("invocation_error", {
        phase,
        code,
        message:
          error instanceof Error
            ? error.message
            : error && typeof error === "object" && "message" in error
              ? String(error.message)
              : String(error),
      })

Comment thread packages/cli/src/cli/invocation.ts Outdated
export type InvocationPhase = "parse" | "validation" | "stdin" | "bootstrap" | "session"

function create(argv: string[]) {
const run = argv.indexOf("run")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 argv indexOf('run') matches non-subcommand tokens.

Suggested change
const run = argv.indexOf("run")
Resolve the subcommand from the actual command tree (or require `run` to be the first non-flag argv token) instead of scanning the entire argv for an exact `run` element.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:7-13):

Problem: argv indexOf('run') matches non-subcommand tokens
Detail: JSON-mode detection uses `argv.indexOf("run")` (first exact `run` token) then scans the remainder for `--format=json`. If a different subcommand accepts a positional/value equal to `run` followed elsewhere by `--format json` (e.g. `aictrl <cmd> --name run --format json`), the detector falsely activates and emits `invocation_start` / `invocation_complete` NDJSON onto an unrelated command's stdout, corrupting its parsed output. The check does not confirm `run` is in the subcommand position (first non-flag token), and any future alias/wrapper for the run subcommand would not be recognized.
Suggested fix: Resolve the subcommand from the actual command tree (or require `run` to be the first non-flag argv token) instead of scanning the entire argv for an exact `run` element.

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

JSON-mode detection uses argv.indexOf("run") (first exact run token) then scans the remainder for --format=json. If a different subcommand accepts a positional/value equal to run followed elsewhere by --format json (e.g. aictrl <cmd> --name run --format json), the detector falsely activates and emits invocation_start / invocation_complete NDJSON onto an unrelated command's stdout, corrupting its parsed output. The check does not confirm run is in the subcommand position (first non-flag token), and any future alias/wrapper for the run subcommand would not be recognized.

  const run = argv.indexOf("run")
  const json =
    run !== -1 &&
    argv.slice(run + 1).some((arg, index, args) => {
      if (arg === "--format=json") return true
      return arg === "--format" && args[index + 1] === "json"
    })

Comment thread packages/cli/src/index.ts
import { Database } from "./storage/db"
import { Invocation, startInvocation } from "./cli/invocation"

startInvocation()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lifecycle wiring duplicated in index.ts and headless.ts.

Suggested change
startInvocation()
In `packages/cli/src/cli/invocation.ts`, export helpers such as `installInvocationProcessHandlers()`, `wireInvocationCliFail(cli)`, and `finalizeInvocation(e?)`, and call them once from each entry point.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/index.ts:34):

Problem: Lifecycle wiring duplicated in index.ts and headless.ts
Detail: The Invocation lifecycle wiring added to `index.ts` and `headless.ts` is byte-for-byte identical across six insertion points: (1) `startInvocation()` top-level call, (2) `Invocation.abort(e)` in `unhandledRejection`, (3) `Invocation.abort(e)` in `uncaughtException`, (4) `if (!Invocation.id) cli.showHelp("log")` guard, (5) `Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")` before `process.exit(1)` in the yargs `.fail()` handler, (6) `Invocation.error(e)` in `catch` + `Invocation.complete()` in `finally`. The two copies can drift (e.g. the `INVOCATION_PARSE_ERROR` code or the showHelp guard condition). Rated NIT rather than MINOR because the surrounding process/yargs/try-catch scaffolding is already duplicated between these two entry points, so the new code merely follows the existing convention.
Suggested fix: In `packages/cli/src/cli/invocation.ts`, export helpers such as `installInvocationProcessHandlers()`, `wireInvocationCliFail(cli)`, and `finalizeInvocation(e?)`, and call them once from each entry point.

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 Invocation lifecycle wiring added to index.ts and headless.ts is byte-for-byte identical across six insertion points: (1) startInvocation() top-level call, (2) Invocation.abort(e) in unhandledRejection, (3) Invocation.abort(e) in uncaughtException, (4) if (!Invocation.id) cli.showHelp("log") guard, (5) Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR") before process.exit(1) in the yargs .fail() handler, (6) Invocation.error(e) in catch + Invocation.complete() in finally. The two copies can drift (e.g. the INVOCATION_PARSE_ERROR code or the showHelp guard condition). Rated NIT rather than MINOR because the surrounding process/yargs/try-catch scaffolding is already duplicated between these two entry points, so the new code merely follows the existing convention.

import { Invocation, startInvocation } from "./cli/invocation"

startInvocation()

process.on("unhandledRejection", (e) => {
  Invocation.abort(e)

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

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

  • 🟠 packages/cli/src/cli/cmd/run.ts:338-342 — fail() process.exit races stdout NDJSON write
  • 🟡 packages/cli/src/cli/invocation.ts:7-13 — argv indexOf('run') matches non-subcommand tokens
  • 🟠 packages/cli/src/cli/invocation.ts:47-68 — Post-session failures report status:completed
  • 🟠 packages/cli/src/cli/invocation.ts:52-58 — Raw error messages leaked to stdout NDJSON
  • packages/cli/src/index.ts:34 — Lifecycle wiring duplicated in index.ts and headless.ts
🤖 Fix all 5 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #96 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:338-342 — fail() process.exit races stdout NDJSON write
   Detail: The new `fail()` helper calls `Invocation.abort()` (which writes `invocation_error` + `invocation_complete` NDJSON via `process.stdout.write`) and then immediately calls `process.exit(1)`. On a non-TTY stdout (pipe/file/CI capture), writes are async-buffered and `process.exit()` does not await the drain, so the terminal NDJSON events can be truncated or lost. This reintroduces the race at every validation failure path (`INVOCATION_INVALID_DIRECTORY`, `INVOCATION_FILE_NOT_FOUND`, `INVOCATION_EMPTY_INPUT`, `INVOCATION_INVALID_ARGUMENTS`, `INVOCATION_SESSION_CREATE_FAILED`). If a flush-then-exit helper exists for the top-level finally path, `fail()` bypasses it. Confidence is moderate because the lifecycle test does capture all three events in piped-stdout scenarios; depending on runtime (Bun may flush synchronously for small writes) the race may not manifest in practice, but the code as written does not guarantee delivery.
   Suggested fix: Route `fail()` through the same flush-then-exit helper used by the top-level `finally` (await stdout `'finish'`/drain before `process.exit`), instead of calling `process.exit(1)` synchronously after `Invocation.abort()`.
2. packages/cli/src/cli/invocation.ts:7-13 — argv indexOf('run') matches non-subcommand tokens
   Detail: JSON-mode detection uses `argv.indexOf("run")` (first exact `run` token) then scans the remainder for `--format=json`. If a different subcommand accepts a positional/value equal to `run` followed elsewhere by `--format json` (e.g. `aictrl <cmd> --name run --format json`), the detector falsely activates and emits `invocation_start` / `invocation_complete` NDJSON onto an unrelated command's stdout, corrupting its parsed output. The check does not confirm `run` is in the subcommand position (first non-flag token), and any future alias/wrapper for the run subcommand would not be recognized.
   Suggested fix: Resolve the subcommand from the actual command tree (or require `run` to be the first non-flag argv token) instead of scanning the entire argv for an exact `run` element.
3. packages/cli/src/cli/invocation.ts:47-68 — Post-session failures report status:completed
   Detail: `error()`'s guard `if (!invocationID || sessionID || failed || completed) return` causes it to silently no-op once `Invocation.link(sessionID)` runs in run.ts. Any later failure (share()/execute()/bootstrap-callback throws, POST /message 500, network drops, agent errors, or an unhandledRejection/uncaughtException arriving mid-session) propagates to the `catch` in headless.ts/index.ts, where `Invocation.error(e)` (and `Invocation.abort(e)` from the global handlers) early-returns because `sessionID` is truthy — so `failed` is never set. The `finally` block then calls `complete()` which emits `{ type: "invocation_complete", status: "completed", sessionID }` even though `process.exitCode = 1` was set in the catch. NDJSON consumers see a successful invocation while the shell sees a non-zero exit code. EVENTS.md hand-waves this with "Errors after a real session has been created use session_error instead", but nothing in this PR guarantees a `session_error` is emitted on every post-link failure path, and the `invocation_complete.status` field directly contradicts the actual outcome. The new lifecycle test only covers pre-link failures (`session()` returning null at L538); no test exercises a post-link failure, so the regression is unguarded.
   Suggested fix: Decouple the `failed` flag from `invocation_error` emission. Either remove `|| sessionID` from the `error()` guard (so invocation_error is still emitted alongside any session_error), or add a `markFailed()` that sets `failed = true` without emitting when `sessionID` is set, and call it from the headless.ts/index.ts catch and from the unhandledRejection/uncaughtException handlers. At minimum, `complete()` must derive status from something a post-link error can affect — e.g. check `process.exitCode !== 0` or accept an explicit status override.
4. packages/cli/src/cli/invocation.ts:52-58 — Raw error messages leaked to stdout NDJSON
   Detail: `Invocation.error()`/`abort()` serialize `error.message` (or `String(error)`) verbatim into the stdout NDJSON stream for every unhandledRejection, uncaughtException, parse error, and run-handler failure. Error messages from network/SDK/auth/filesystem layers routinely carry absolute paths, internal URLs, auth tokens, API keys, or env-derived values. Stdout is the machine-readable channel that consumers pipe to log aggregators and CI capture, so this widens the disclosure surface beyond the pre-existing `Log.Default.error` path (which is stderr/file-scoped). The `String(error)` else branch additionally dumps arbitrary object graphs for non-Error rejections. This is reachable from every error path that the new lifecycle instrumentation wraps.
   Suggested fix: Sanitize before emitting: cap length, redact URLs/tokens/paths, or emit only the stable `code` + `phase` and keep the raw message on the existing `Log` channel. At minimum drop the `String(error)` fallback and never serialize arbitrary objects.
5. packages/cli/src/index.ts:34 — Lifecycle wiring duplicated in index.ts and headless.ts
   Detail: The Invocation lifecycle wiring added to `index.ts` and `headless.ts` is byte-for-byte identical across six insertion points: (1) `startInvocation()` top-level call, (2) `Invocation.abort(e)` in `unhandledRejection`, (3) `Invocation.abort(e)` in `uncaughtException`, (4) `if (!Invocation.id) cli.showHelp("log")` guard, (5) `Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")` before `process.exit(1)` in the yargs `.fail()` handler, (6) `Invocation.error(e)` in `catch` + `Invocation.complete()` in `finally`. The two copies can drift (e.g. the `INVOCATION_PARSE_ERROR` code or the showHelp guard condition). Rated NIT rather than MINOR because the surrounding process/yargs/try-catch scaffolding is already duplicated between these two entry points, so the new code merely follows the existing convention.
   Suggested fix: In `packages/cli/src/cli/invocation.ts`, export helpers such as `installInvocationProcessHandlers()`, `wireInvocationCliFail(cli)`, and `finalizeInvocation(e?)`, and call them once from each entry point.
📋 Out-of-diff findings (5)
Sev Location Finding
🟠 packages/cli/src/cli/cmd/run.ts:338-342 fail() process.exit races stdout NDJSON write
🟡 packages/cli/src/cli/invocation.ts:7-13 argv indexOf('run') matches non-subcommand tokens
🟠 packages/cli/src/cli/invocation.ts:47-68 Post-session failures report status:completed
🟠 packages/cli/src/cli/invocation.ts:52-58 Raw error messages leaked to stdout NDJSON
packages/cli/src/index.ts:34 Lifecycle wiring duplicated in index.ts and headless.ts

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


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

@byapparov

Copy link
Copy Markdown
Contributor Author
Finding Verdict / action Resolution Verification
fail() races NDJSON output TRUE · FIX Invocation now serializes lifecycle writes and exposes flush(); validation, exception, parse, and final exit paths wait for the queue before forced exit. 100 KB piped validation failure is complete and parseable in invocation-lifecycle.test.ts.
argv.indexOf("run") false match TRUE · FIX Detection now resolves run only in command position while accounting for supported global options. Regression covers session list run --format json plus --log-level ERROR run --format json.
Post-session failure reports completed TRUE · FIX Failure state is now recorded after link() while duplicate invocation_error emission remains suppressed when session-scoped events apply. Regression induces malformed /config JSON after session creation and observes non-zero exit plus invocation_complete.status: "error".
Raw error leakage TRUE · FIX NDJSON now emits a stable phase summary; detailed raw errors remain on stderr and in the existing log channel. Regressions assert a 100 KB path and a post-session secret are absent from stdout events.
Duplicate entry-point wiring TRUE · IGNORE The duplication follows the existing distinct index.ts / headless.ts process and yargs scaffolding. Extracting only lifecycle fragments would be an architectural refactor without changing behavior, so no follow-up is warranted for this non-defect nit. Both entry points share the same focused lifecycle coverage and passed the full suite.

Remediation commit: 9db1fe2c3ae3a088651f63327c6d1cf52a75e3b3.

Verification:

  • bun test test/cli/invocation-lifecycle.test.ts --timeout 30000 — PASS (11 tests, 103 assertions)
  • bun test --timeout 30000 — PASS (1,335 passed, 7 skipped, 0 failed)
  • bun run typecheck — PASS
  • push hook workspace typecheck — PASS (6/6 tasks)

The push triggered the provider re-review and CI workflows for the new head.

@@ -0,0 +1,118 @@
import { EOL } from "os"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Invocation lifecycle wiring duplicated in both entrypoints.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:1-118):

Problem: Invocation lifecycle wiring duplicated in both entrypoints
Detail: The PR adds near-byte-identical invocation lifecycle plumbing to BOTH packages/cli/src/headless.ts and packages/cli/src/index.ts: the `startInvocation()` call, two async `process.on("unhandledRejection"/"uncaughtException")` handlers that each do `Invocation.abort(e); Log...; await Invocation.flush(); process.exit(1)`, the same `.fail()` body (`if (!Invocation.id) cli.showHelp("log"); ... Invocation.abort(msg ?? "...", "INVOCATION_PARSE_ERROR"); if (Invocation.id) throw new Error(...); process.exit(1)`), `Invocation.error(e)` in the catch block, and `Invocation.complete(); await Invocation.flush()` in finally. This PR introduces a brand-new module (`invocation.ts`) that is the natural home for this wiring, yet the lifecycle setup is inlined into both entrypoints. The repo's own consistency rule is "Functions used across files are moved to a shared module, not duplicated"; the existing exception-handler.test.ts (also touched here, thresholds relaxed 200→250 in lockstep for both files) already has to assert the same shape twice, proving the two must be hand-synced on every future lifecycle change. Suggested fix: export helpers from invocation.ts such as `installInvocationProcessHandlers()` and `finalizeInvocation()`, and a `wrapCliFail(cli)` shim, then have both entrypoints call them.
Suggested fix: Move the shared wiring into invocation.ts: export `installInvocationProcessHandlers()` (registers the two async process handlers), `finalizeInvocation()` (complete + flush), and `wrapCliFail(cli)` for the yargs `.fail()` shim. Have headless.ts and index.ts call those instead of inlining ~14 lines each.

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 near-byte-identical invocation lifecycle plumbing to BOTH packages/cli/src/headless.ts and packages/cli/src/index.ts: the startInvocation() call, two async process.on("unhandledRejection"/"uncaughtException") handlers that each do Invocation.abort(e); Log...; await Invocation.flush(); process.exit(1), the same .fail() body (if (!Invocation.id) cli.showHelp("log"); ... Invocation.abort(msg ?? "...", "INVOCATION_PARSE_ERROR"); if (Invocation.id) throw new Error(...); process.exit(1)), Invocation.error(e) in the catch block, and Invocation.complete(); await Invocation.flush() in finally. This PR introduces a brand-new module (invocation.ts) that is the natural home for this wiring, yet the lifecycle setup is inlined into both entrypoints. The repo's own consistency rule is "Functions used across files are moved to a shared module, not duplicated"; the existing exception-handler.test.ts (also touched here, thresholds relaxed 200→250 in lockstep for both files) already has to assert the same shape twice, proving the two must be hand-synced on every future lifecycle change. Suggested fix: export helpers from invocation.ts such as installInvocationProcessHandlers() and finalizeInvocation(), and a wrapCliFail(cli) shim, then have both entrypoints call them.

// headless.ts (lines 22-37, head):
startInvocation()

process.on("unhandledRejection", async (e) => {
  Invocation.abort(e)
  Log.Default.error("rejection", { ... })
  await Invocation.flush()
  process.exit(1)
})

process.on("uncaughtException", async (e) => {
  Invocation.abort(e)
  Log.Default.error("exception", { ... })
  await Invocation.flush()
  process.exit(1)
})

// index.ts: identical block, must be hand-synced on every change.

@@ -465,7 +475,9 @@ export const RunCommand = cmd({

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.

🟡 Two emit() helpers diverge on schemaVersion handling.

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

Problem: Two emit() helpers diverge on schemaVersion handling
Detail: There are now two sibling `emit(type, data)` functions building NDJSON envelopes. `invocation.ts` constructs `{type, timestamp, schemaVersion: SCHEMA_VERSION, invocationID, ...data}` and queue-serializes writes; `run.ts` constructs `{type, timestamp, invocationID: Invocation.id, sessionID, ...data}` synchronously and does NOT inject `schemaVersion` — it relies on every caller remembering to pass `schemaVersion` inside `data`. EVENTS.md (modified by this PR) states the schema is versioned via BOTH `invocation_start.schemaVersion` and `session_start.schemaVersion`, so the two emit paths are responsible for the same contract but enforce it differently: an `invocation_*` event can never forget `schemaVersion`, while a future `session_*` event added in run.ts that forgets to include `schemaVersion` in its data object will silently violate the documented schema. The duplication also means any future envelope field (cliVersion, pid, etc.) must be added in two places.
Suggested fix: Extract a single `writeEvent(type, data, ctx)` (or `buildEnvelope(type, data, ctx)`) helper — ideally on the Invocation module since it already owns the queued-write plumbing and `SCHEMA_VERSION` — and have run.ts route `session_*` events through it. This guarantees `schemaVersion` is always injected and that session events flush in order with invocation 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

There are now two sibling emit(type, data) functions building NDJSON envelopes. invocation.ts constructs {type, timestamp, schemaVersion: SCHEMA_VERSION, invocationID, ...data} and queue-serializes writes; run.ts constructs {type, timestamp, invocationID: Invocation.id, sessionID, ...data} synchronously and does NOT inject schemaVersion — it relies on every caller remembering to pass schemaVersion inside data. EVENTS.md (modified by this PR) states the schema is versioned via BOTH invocation_start.schemaVersion and session_start.schemaVersion, so the two emit paths are responsible for the same contract but enforce it differently: an invocation_* event can never forget schemaVersion, while a future session_* event added in run.ts that forgets to include schemaVersion in its data object will silently violate the documented schema. The duplication also means any future envelope field (cliVersion, pid, etc.) must be added in two places.

      function emit(type: string, data: Record<string, unknown>) {
        if (args.format === "json") {
          process.stdout.write(
            JSON.stringify({ type, timestamp: Date.now(), invocationID: Invocation.id, sessionID, ...data }) + EOL,
          )
          return true
        }
        return false

export type InvocationPhase = "parse" | "validation" | "stdin" | "bootstrap" | "session"

function create(argv: string[]) {
const run = (() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Argv sniffer drops lifecycle events for unknown globals.

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

Problem: Argv sniffer drops lifecycle events for unknown globals
Detail: `create(argv)` re-implements argv parsing to decide whether to emit lifecycle events. The detection loop returns `arg === "run" ? index : -1` on the FIRST non-skipped token, and the skip-list only contains `--print-logs`, `--log-level`, and `--log-level=...`. Any other global option the CLI parser accepts (a short alias of `--log-level`, a future global, or any option-that-takes-a-value like a hypothetical `--config`/`--verbose`) appearing before the literal `run` token makes the loop return -1, so `invocationID` stays undefined and NO `invocation_start`/`invocation_error`/`invocation_complete` events are emitted even though the user passed `--format json`. The error is silent — consumers waiting on `invocation_complete` to detect CLI exit just hang. The skip-list is also not derived from the yargs global-option source-of-truth, so it will drift the moment a new global is added; the existing `recognizes run after global options` test only exercises `--log-level`, so a regression would not be caught. Could not verify against packages/cli/src/global.ts from the diff alone; if additional globals exist today this is a live bug rather than only a maintenance hazard.
Suggested fix: Either (a) defer detection to yargs — emit `invocation_start` after `cli.parse()` resolves the run command + format=json (and before stdin is read) — so the real parser is the single source of truth; or (b) if a pre-parse sniff must stay, make the leading-skip loop generic: skip any token starting with `--` (consuming its value when the option is registered as taking one) instead of hard-coding two flags. At minimum, derive the skip set from the same yargs global-option config and add a regression test asserting every yargs global is in the skip-list.

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

create(argv) re-implements argv parsing to decide whether to emit lifecycle events. The detection loop returns arg === "run" ? index : -1 on the FIRST non-skipped token, and the skip-list only contains --print-logs, --log-level, and --log-level=.... Any other global option the CLI parser accepts (a short alias of --log-level, a future global, or any option-that-takes-a-value like a hypothetical --config/--verbose) appearing before the literal run token makes the loop return -1, so invocationID stays undefined and NO invocation_start/invocation_error/invocation_complete events are emitted even though the user passed --format json. The error is silent — consumers waiting on invocation_complete to detect CLI exit just hang. The skip-list is also not derived from the yargs global-option source-of-truth, so it will drift the moment a new global is added; the existing recognizes run after global options test only exercises --log-level, so a regression would not be caught. Could not verify against packages/cli/src/global.ts from the diff alone; if additional globals exist today this is a live bug rather than only a maintenance hazard.

  const run = (() => {
    for (let index = 0; index < argv.length; index++) {
      const arg = argv[index]
      if (arg === "--print-logs") continue
      if (arg === "--log-level") {
        index++
        continue
      }
      if (arg.startsWith("--log-level=")) continue
      return arg === "run" ? index : -1
    }
    return -1
  })()

}
}

let current = create([])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dummy Invocation at module load is immediately replaced.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:89):

Problem: Dummy Invocation at module load is immediately replaced
Detail: At module load, `let current = create([])` runs the full argv scan, allocates the `writes` promise chain, and closes over `emit`/`phase`/`link`/`error`/`complete` — all of which are immediately discarded the moment `startInvocation()` is called from headless.ts / index.ts (which happens at the top of every entrypoint before any other `Invocation.*` API is touched). Because `argv=[]` contains no `run` token, `invocationID` is undefined and every method on this dummy is a silent no-op, so the only effect is wasted allocation plus a false impression that the module is usable before `startInvocation()`. The pattern also hides bugs: a caller that fires `Invocation.error(...)` before `startInvocation()` (e.g. from a top-level await that throws during import) will silently swallow the lifecycle signal rather than fail loudly.
Suggested fix: Declare `let current: ReturnType<typeof create> | undefined` and either (a) have each `Invocation.*` method throw if `current` is unset, or (b) lazily initialize on first access. If the no-op default is intentional for test-harness safety, add a one-line comment saying so.

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

At module load, let current = create([]) runs the full argv scan, allocates the writes promise chain, and closes over emit/phase/link/error/complete — all of which are immediately discarded the moment startInvocation() is called from headless.ts / index.ts (which happens at the top of every entrypoint before any other Invocation.* API is touched). Because argv=[] contains no run token, invocationID is undefined and every method on this dummy is a silent no-op, so the only effect is wasted allocation plus a false impression that the module is usable before startInvocation(). The pattern also hides bugs: a caller that fires Invocation.error(...) before startInvocation() (e.g. from a top-level await that throws during import) will silently swallow the lifecycle signal rather than fail loudly.

}

let current = create([])

export const Invocation = {
  get id() {
    return current.id
  },

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

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

  • 🟡 packages/cli/src/cli/cmd/run.ts:477-483 — Two emit() helpers diverge on schemaVersion handling
  • 🟠 packages/cli/src/cli/invocation.ts:1-118 — Invocation lifecycle wiring duplicated in both entrypoints
  • 🟡 packages/cli/src/cli/invocation.ts:7-25 — Argv sniffer drops lifecycle events for unknown globals
  • packages/cli/src/cli/invocation.ts:89 — Dummy Invocation at module load is immediately replaced
🤖 Fix all 4 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #96 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:477-483 — Two emit() helpers diverge on schemaVersion handling
   Detail: There are now two sibling `emit(type, data)` functions building NDJSON envelopes. `invocation.ts` constructs `{type, timestamp, schemaVersion: SCHEMA_VERSION, invocationID, ...data}` and queue-serializes writes; `run.ts` constructs `{type, timestamp, invocationID: Invocation.id, sessionID, ...data}` synchronously and does NOT inject `schemaVersion` — it relies on every caller remembering to pass `schemaVersion` inside `data`. EVENTS.md (modified by this PR) states the schema is versioned via BOTH `invocation_start.schemaVersion` and `session_start.schemaVersion`, so the two emit paths are responsible for the same contract but enforce it differently: an `invocation_*` event can never forget `schemaVersion`, while a future `session_*` event added in run.ts that forgets to include `schemaVersion` in its data object will silently violate the documented schema. The duplication also means any future envelope field (cliVersion, pid, etc.) must be added in two places.
   Suggested fix: Extract a single `writeEvent(type, data, ctx)` (or `buildEnvelope(type, data, ctx)`) helper — ideally on the Invocation module since it already owns the queued-write plumbing and `SCHEMA_VERSION` — and have run.ts route `session_*` events through it. This guarantees `schemaVersion` is always injected and that session events flush in order with invocation events.
2. packages/cli/src/cli/invocation.ts:1-118 — Invocation lifecycle wiring duplicated in both entrypoints
   Detail: The PR adds near-byte-identical invocation lifecycle plumbing to BOTH packages/cli/src/headless.ts and packages/cli/src/index.ts: the `startInvocation()` call, two async `process.on("unhandledRejection"/"uncaughtException")` handlers that each do `Invocation.abort(e); Log...; await Invocation.flush(); process.exit(1)`, the same `.fail()` body (`if (!Invocation.id) cli.showHelp("log"); ... Invocation.abort(msg ?? "...", "INVOCATION_PARSE_ERROR"); if (Invocation.id) throw new Error(...); process.exit(1)`), `Invocation.error(e)` in the catch block, and `Invocation.complete(); await Invocation.flush()` in finally. This PR introduces a brand-new module (`invocation.ts`) that is the natural home for this wiring, yet the lifecycle setup is inlined into both entrypoints. The repo's own consistency rule is "Functions used across files are moved to a shared module, not duplicated"; the existing exception-handler.test.ts (also touched here, thresholds relaxed 200→250 in lockstep for both files) already has to assert the same shape twice, proving the two must be hand-synced on every future lifecycle change. Suggested fix: export helpers from invocation.ts such as `installInvocationProcessHandlers()` and `finalizeInvocation()`, and a `wrapCliFail(cli)` shim, then have both entrypoints call them.
   Suggested fix: Move the shared wiring into invocation.ts: export `installInvocationProcessHandlers()` (registers the two async process handlers), `finalizeInvocation()` (complete + flush), and `wrapCliFail(cli)` for the yargs `.fail()` shim. Have headless.ts and index.ts call those instead of inlining ~14 lines each.
3. packages/cli/src/cli/invocation.ts:7-25 — Argv sniffer drops lifecycle events for unknown globals
   Detail: `create(argv)` re-implements argv parsing to decide whether to emit lifecycle events. The detection loop returns `arg === "run" ? index : -1` on the FIRST non-skipped token, and the skip-list only contains `--print-logs`, `--log-level`, and `--log-level=...`. Any other global option the CLI parser accepts (a short alias of `--log-level`, a future global, or any option-that-takes-a-value like a hypothetical `--config`/`--verbose`) appearing before the literal `run` token makes the loop return -1, so `invocationID` stays undefined and NO `invocation_start`/`invocation_error`/`invocation_complete` events are emitted even though the user passed `--format json`. The error is silent — consumers waiting on `invocation_complete` to detect CLI exit just hang. The skip-list is also not derived from the yargs global-option source-of-truth, so it will drift the moment a new global is added; the existing `recognizes run after global options` test only exercises `--log-level`, so a regression would not be caught. Could not verify against packages/cli/src/global.ts from the diff alone; if additional globals exist today this is a live bug rather than only a maintenance hazard.
   Suggested fix: Either (a) defer detection to yargs — emit `invocation_start` after `cli.parse()` resolves the run command + format=json (and before stdin is read) — so the real parser is the single source of truth; or (b) if a pre-parse sniff must stay, make the leading-skip loop generic: skip any token starting with `--` (consuming its value when the option is registered as taking one) instead of hard-coding two flags. At minimum, derive the skip set from the same yargs global-option config and add a regression test asserting every yargs global is in the skip-list.
4. packages/cli/src/cli/invocation.ts:89 — Dummy Invocation at module load is immediately replaced
   Detail: At module load, `let current = create([])` runs the full argv scan, allocates the `writes` promise chain, and closes over `emit`/`phase`/`link`/`error`/`complete` — all of which are immediately discarded the moment `startInvocation()` is called from headless.ts / index.ts (which happens at the top of every entrypoint before any other `Invocation.*` API is touched). Because `argv=[]` contains no `run` token, `invocationID` is undefined and every method on this dummy is a silent no-op, so the only effect is wasted allocation plus a false impression that the module is usable before `startInvocation()`. The pattern also hides bugs: a caller that fires `Invocation.error(...)` before `startInvocation()` (e.g. from a top-level await that throws during import) will silently swallow the lifecycle signal rather than fail loudly.
   Suggested fix: Declare `let current: ReturnType<typeof create> | undefined` and either (a) have each `Invocation.*` method throw if `current` is unset, or (b) lazily initialize on first access. If the no-op default is intentional for test-harness safety, add a one-line comment saying so.
📋 Out-of-diff findings (4)
Sev Location Finding
🟡 packages/cli/src/cli/cmd/run.ts:477-483 Two emit() helpers diverge on schemaVersion handling
🟠 packages/cli/src/cli/invocation.ts:1-118 Invocation lifecycle wiring duplicated in both entrypoints
🟡 packages/cli/src/cli/invocation.ts:7-25 Argv sniffer drops lifecycle events for unknown globals
packages/cli/src/cli/invocation.ts:89 Dummy Invocation at module load is immediately replaced

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


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

@byapparov

Copy link
Copy Markdown
Contributor Author
Finding Verdict / action Evidence and resolution Verification
Two emit helpers diverge on schemaVersion FALSE · IGNORE EVENTS.md explicitly versions the stream through invocation_start and session_start, not every event. run.ts supplies SCHEMA_VERSION at the sole required session start call; the remaining concern is hypothetical future drift, not a current contract violation. Existing lifecycle test asserts both required start versions.
Lifecycle wiring duplicated in both entrypoints TRUE · IGNORE The repeated lifecycle calls are coupled to pre-existing, distinct process/yargs/catch/finally scaffolding in index.ts and headless.ts. No behavioral drift exists at this head, and extracting logging and yargs ownership into invocation.ts would be an architectural refactor rather than defect remediation. No follow-up is warranted without a concrete behavior gap. Focused lifecycle and full CLI suites cover both entrypoints and remain green.
Argv sniffer drops lifecycle events for globals TRUE · FIX Unknown globals are rejected by strict yargs today, and there is no short log-level alias. One accepted current form was missing: --print-logs=false. invocation.ts now recognizes the boolean assignment form without speculating about future options. Regression proves --print-logs=false run --format json emits the complete validation-error lifecycle.
Dummy initial Invocation TRUE · IGNORE The allocation is redundant, but repository search shows both executable entrypoints call startInvocation() synchronously before any Invocation.* use; no pre-start caller is reachable. Changing initialization semantics would add no user-visible correctness benefit. Current call graph inspection plus full suite.

Final remediation commit: 35ac08817.

Verification:

  • bun test test/cli/invocation-lifecycle.test.ts --timeout 30000 — PASS (11 tests, 116 assertions)
  • bun test --timeout 30000 — PASS (1,335 passed, 7 skipped, 0 failed; 3,136 assertions)
  • bun run typecheck — PASS
  • push-hook workspace typecheck — PASS (6/6 tasks)

This is the second and final bounded remediation round; no further automated re-review is being requested.

Comment thread packages/cli/src/cli/invocation.ts Outdated
})()
const json =
run !== -1 &&
argv.slice(run + 1).some((arg, index, args) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 --format=json detector ignores the `--` separator.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:23-26):

Problem: --format=json detector ignores the `--` separator
Detail: The JSON-mode detector does `argv.slice(run + 1).some(arg => arg === "--format=json" || (arg === "--format" && next === "json"))` without stopping at the `--` separator that yargs uses to treat all subsequent tokens as positional message text. For an invocation like `aictrl run -- --format=json "do something"`, yargs sets `args.format` to the default (because `--format=json` is a positional after `--`), but the detector still matches the literal and generates an `invocationID`. The result: `invocation_start` and `invocation_complete` are emitted to stdout (because the invocation layer thinks it's in JSON mode), while the run-loop `emit()` in `run.ts` stays silent (because `args.format !== "json"`). Consumers of stdout see a bare invocation envelope with no session events in between, violating the deterministic-envelope goal of the PR. The author thought about positional false-positives for non-`run` subcommands (test: `does not start a run invocation for a positional token on another command`) but not for the `--` separator on `run` itself. The detector also only recognizes two flag spellings, so it can silently diverge from yargs in the other direction for any future alias.
Suggested fix: Stop the `--format` scan at the first `--` token inside the `.some()` callback:

```ts
argv.slice(run + 1).some((arg, index, args) => {
  if (arg === "--") return false
  if (arg === "--format=json") return true
  return arg === "--format" && args[index + 1] === "json"
})
```

Better long-term: do not re-parse argv at all. Pass the parsed `args.format` value into the invocation context from the run handler so the lifecycle layer's notion of JSON mode is identical to what the run handler actually uses.

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 JSON-mode detector does argv.slice(run + 1).some(arg => arg === "--format=json" || (arg === "--format" && next === "json")) without stopping at the -- separator that yargs uses to treat all subsequent tokens as positional message text. For an invocation like aictrl run -- --format=json "do something", yargs sets args.format to the default (because --format=json is a positional after --), but the detector still matches the literal and generates an invocationID. The result: invocation_start and invocation_complete are emitted to stdout (because the invocation layer thinks it's in JSON mode), while the run-loop emit() in run.ts stays silent (because args.format !== "json"). Consumers of stdout see a bare invocation envelope with no session events in between, violating the deterministic-envelope goal of the PR. The author thought about positional false-positives for non-run subcommands (test: does not start a run invocation for a positional token on another command) but not for the -- separator on run itself. The detector also only recognizes two flag spellings, so it can silently diverge from yargs in the other direction for any future alias.

  const json =
    run !== -1 &&
    argv.slice(run + 1).some((arg, index, args) => {
      if (arg === "--format=json") return true
      return arg === "--format" && args[index + 1] === "json"
    })

cli.showHelp("log")
if (!Invocation.id) cli.showHelp("log")
}
if (err) throw err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 INVOCATION_PARSE_ERROR code is unreachable in practice.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/headless.ts:130-132):

Problem: INVOCATION_PARSE_ERROR code is unreachable in practice
Detail: In the yargs `.fail()` handler, `if (err) throw err` on line 130 runs BEFORE `Invocation.abort(msg, "INVOCATION_PARSE_ERROR")` on line 131. Yargs passes a truthy `err` object alongside `msg` for every parse-error case the handler checks ("Unknown argument", "Invalid values:", etc.), so the throw fires first and control transfers to the `catch (e)` block around `await cli.parse()`. That catch calls `Invocation.error(e)` with the DEFAULT code `INVOCATION_FAILED`. As a result, the documented `INVOCATION_PARSE_ERROR` code is dead code in the common parse-error case — the emitted `invocation_error.code` is `INVOCATION_FAILED` instead. The test `reports argument parsing failure` passes only because `expectFailure` asserts on `error.phase` and `error.message` but never on `error.code`, so this regression is not caught. Any downstream consumer branching on `code === "INVOCATION_PARSE_ERROR"` will never match. The identical bug exists in `packages/cli/src/index.ts` (same handler around line 173).
Suggested fix: Call `Invocation.abort(...)` BEFORE the `if (err) throw err`, or set the code on the error before rethrowing. Example:

```ts
.fail((msg, err) => {
  const isParseError =
    msg?.startsWith("Unknown argument") || msg?.startsWith("Invalid values:")
  if (isParseError) {
    Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")
    if (err) throw err
    if (!Invocation.id) cli.showHelp("log")
    if (Invocation.id) throw new Error(msg ?? "Invalid command line arguments")
    process.exit(1)
  }
  if (err) throw err
  process.exit(1)
})
```

Apply the same fix to `packages/cli/src/index.ts`. Also add an assertion on `error.code` in the `reports argument parsing failure` test so this does not regress.

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 the yargs .fail() handler, if (err) throw err on line 130 runs BEFORE Invocation.abort(msg, "INVOCATION_PARSE_ERROR") on line 131. Yargs passes a truthy err object alongside msg for every parse-error case the handler checks ("Unknown argument", "Invalid values:", etc.), so the throw fires first and control transfers to the catch (e) block around await cli.parse(). That catch calls Invocation.error(e) with the DEFAULT code INVOCATION_FAILED. As a result, the documented INVOCATION_PARSE_ERROR code is dead code in the common parse-error case — the emitted invocation_error.code is INVOCATION_FAILED instead. The test reports argument parsing failure passes only because expectFailure asserts on error.phase and error.message but never on error.code, so this regression is not caught. Any downstream consumer branching on code === "INVOCATION_PARSE_ERROR" will never match. The identical bug exists in packages/cli/src/index.ts (same handler around line 173).

      if (err) throw err
      if (!Invocation.id) cli.showHelp("log")
    }
    if (err) throw err
    Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")
    if (Invocation.id) throw new Error(msg ?? "Invalid command line arguments")
    process.exit(1)
  })

Comment thread packages/cli/src/index.ts
cli.showHelp("log")
if (!Invocation.id) cli.showHelp("log")
}
if (err) throw err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 INVOCATION_PARSE_ERROR code is unreachable in practice.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/index.ts:173-175):

Problem: INVOCATION_PARSE_ERROR code is unreachable in practice
Detail: Same bug as in `packages/cli/src/headless.ts`: in the yargs `.fail()` handler, `if (err) throw err` on line 173 runs BEFORE `Invocation.abort(msg, "INVOCATION_PARSE_ERROR")` on line 174. Yargs passes a truthy `err` alongside `msg` for every parse-error case ("Unknown argument", "Invalid values:"), so the throw fires first and control transfers to the `catch (e)` block. That catch calls `Invocation.error(e)` with the DEFAULT code `INVOCATION_FAILED`. The documented `INVOCATION_PARSE_ERROR` code is therefore dead code for the common parse-error path, and consumers branching on `code === "INVOCATION_PARSE_ERROR"` will never match.
Suggested fix: Call `Invocation.abort(..., "INVOCATION_PARSE_ERROR")` BEFORE `if (err) throw err`, so the code is set on the invocation before the throw propagates to the catch block. Apply the same fix to `packages/cli/src/headless.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

Same bug as in packages/cli/src/headless.ts: in the yargs .fail() handler, if (err) throw err on line 173 runs BEFORE Invocation.abort(msg, "INVOCATION_PARSE_ERROR") on line 174. Yargs passes a truthy err alongside msg for every parse-error case ("Unknown argument", "Invalid values:"), so the throw fires first and control transfers to the catch (e) block. That catch calls Invocation.error(e) with the DEFAULT code INVOCATION_FAILED. The documented INVOCATION_PARSE_ERROR code is therefore dead code for the common parse-error path, and consumers branching on code === "INVOCATION_PARSE_ERROR" will never match.

      if (err) throw err
      if (!Invocation.id) cli.showHelp("log")
    }
    if (err) throw err
    Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")
    if (Invocation.id) throw new Error(msg ?? "Invalid command line arguments")
    process.exit(1)
  })

sessionID = id
},
error(_error: unknown, code = "INVOCATION_FAILED") {
if (!invocationID || failed || completed) return

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-ops after complete(); late rejection misreports.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:66-74):

Problem: abort() no-ops after complete(); late rejection misreports
Detail: Both `error()` and `complete()` short-circuit once `completed = true`. The `finally` block in `headless.ts` / `index.ts` runs `Invocation.complete()` → `await Invocation.flush()` → `process.exit()`. If an `unhandledRejection` (or `uncaughtException`) arrives during the `await Invocation.flush()` window — which is a real async gap where the event loop runs — the rejection handler calls `Invocation.abort(e)`, but `error()` returns early (`completed === true`) and `complete()` is also a no-op. The NDJSON stream therefore already carries `invocation_complete.status = "completed"` from the happy path, while the process actually exits with code 1 from the rejection handler. Consumers trusting the lifecycle envelope as the source of truth for invocation result get a false success in the exact error path they most need to trust. The window is narrow but reachable (any fire-and-forget promise that rejects during the flush drain will hit it).
Suggested fix: Track a `finalized` flag separately from the `completed` flag, or have `abort()` emit a corrective event when an error arrives after completion — e.g. re-emit (or revise) `invocation_complete` with `status: "error"`, or emit a dedicated `invocation_error` so the lifecycle stream always agrees with the real exit code. At minimum, document this edge case in EVENTS.md so consumers know that a late rejection after `invocation_complete` can still flip the process exit code.

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 error() and complete() short-circuit once completed = true. The finally block in headless.ts / index.ts runs Invocation.complete()await Invocation.flush()process.exit(). If an unhandledRejection (or uncaughtException) arrives during the await Invocation.flush() window — which is a real async gap where the event loop runs — the rejection handler calls Invocation.abort(e), but error() returns early (completed === true) and complete() is also a no-op. The NDJSON stream therefore already carries invocation_complete.status = "completed" from the happy path, while the process actually exits with code 1 from the rejection handler. Consumers trusting the lifecycle envelope as the source of truth for invocation result get a false success in the exact error path they most need to trust. The window is narrow but reachable (any fire-and-forget promise that rejects during the flush drain will hit it).

    error(_error: unknown, code = "INVOCATION_FAILED") {
      if (!invocationID || failed || completed) return
      failed = true
      if (sessionID) return
      emit("invocation_error", {
        phase,
        code,
        message: `Invocation failed during ${phase}`,
      })
    },

Comment thread packages/cli/src/cli/invocation.ts Outdated
invocationID,
...data,
}) + EOL
writes = writes.then(

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() may reject; handlers await without try/catch.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/cli/invocation.ts:45-50):

Problem: flush() may reject; handlers await without try/catch
Detail: `emit()` chains writes via `writes = writes.then(() => new Promise((resolve) => process.stdout.write(line, () => resolve())))`. If `process.stdout.write` ever throws synchronously — e.g. stdout was destroyed because a downstream consumer like `head` exited early and closed the pipe — the `new Promise` executor throws, the Promise constructor converts that into a rejection, and the whole `writes` chain rejects with no `.catch()`. `flush()` then returns a rejected promise. The `unhandledRejection` / `uncaughtException` handlers in `headless.ts` and `index.ts` do `await Invocation.flush()` with no try/catch, so the handler itself throws and never reaches `process.exit(1)`, producing a fresh uncaughtException that re-enters the same handler — potentially looping or hanging instead of terminating. The window is narrow (stdout must throw synchronously rather than emit 'error'), but the failure mode is bad: the CLI cannot exit cleanly when its output stream is broken, which is exactly when a consumer is most likely to have closed it.
Suggested fix: Wrap the write in a try/catch and have the callback's err argument reject explicitly; and wrap `await Invocation.flush()` in the process error handlers with try/catch so a flush failure can never prevent `process.exit(1)`:

```ts
process.on("unhandledRejection", async (e) => {
  Invocation.abort(e)
  Log.Default.error("rejection", { ... })
  try { await Invocation.flush() } catch {}
  process.exit(1)
})
```

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

emit() chains writes via writes = writes.then(() => new Promise((resolve) => process.stdout.write(line, () => resolve()))). If process.stdout.write ever throws synchronously — e.g. stdout was destroyed because a downstream consumer like head exited early and closed the pipe — the new Promise executor throws, the Promise constructor converts that into a rejection, and the whole writes chain rejects with no .catch(). flush() then returns a rejected promise. The unhandledRejection / uncaughtException handlers in headless.ts and index.ts do await Invocation.flush() with no try/catch, so the handler itself throws and never reaches process.exit(1), producing a fresh uncaughtException that re-enters the same handler — potentially looping or hanging instead of terminating. The window is narrow (stdout must throw synchronously rather than emit 'error'), but the failure mode is bad: the CLI cannot exit cleanly when its output stream is broken, which is exactly when a consumer is most likely to have closed it.

    writes = writes.then(
      () =>
        new Promise<void>((resolve) => {
          process.stdout.write(line, () => resolve())
        }),
    )

import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { Invocation, startInvocation } from "./cli/invocation"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Lifecycle wiring duplicated in headless.ts and index.ts.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #96, packages/cli/src/headless.ts:23-34):

Problem: Lifecycle wiring duplicated in headless.ts and index.ts
Detail: This PR adds the same ~15 lines of Invocation lifecycle wiring to BOTH `headless.ts` and `index.ts`: the `import { Invocation, startInvocation }` line, the top-level `startInvocation()` call, the `Invocation.abort(e)` + `await Invocation.flush()` additions inside both `unhandledRejection` and `uncaughtException` handlers, the `Invocation.abort(..., "INVOCATION_PARSE_ERROR")` + `if (Invocation.id) throw new Error(...)` block in the yargs `.fail` handler, the `Invocation.error(e)` line in `catch`, and the `Invocation.complete()` + `await Invocation.flush()` pair in `finally`. The two files were already near-duplicates; this PR extends the byte-for-byte duplication. Any future fix to the lifecycle wiring (e.g. the INVOCATION_PARSE_ERROR bug also raised in this review) must be applied to both files in lockstep, or the two entry points will drift. The project convention (per the consistency checklist) is to move cross-file behaviour into a shared module rather than copy it.
Suggested fix: Extract the process-handler registrations, the yargs `.fail` Invocation hook, and the try/catch/finally Invocation cleanup into a single shared function exported from `invocation.ts` (e.g. `registerInvocationLifecycle(cli)`), and call it once from each entry point. The two INVOCATION_PARSE_ERROR bugs raised elsewhere in this review are a concrete example of the maintenance cost this duplication creates.

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

This PR adds the same ~15 lines of Invocation lifecycle wiring to BOTH headless.ts and index.ts: the import { Invocation, startInvocation } line, the top-level startInvocation() call, the Invocation.abort(e) + await Invocation.flush() additions inside both unhandledRejection and uncaughtException handlers, the Invocation.abort(..., "INVOCATION_PARSE_ERROR") + if (Invocation.id) throw new Error(...) block in the yargs .fail handler, the Invocation.error(e) line in catch, and the Invocation.complete() + await Invocation.flush() pair in finally. The two files were already near-duplicates; this PR extends the byte-for-byte duplication. Any future fix to the lifecycle wiring (e.g. the INVOCATION_PARSE_ERROR bug also raised in this review) must be applied to both files in lockstep, or the two entry points will drift. The project convention (per the consistency checklist) is to move cross-file behaviour into a shared module rather than copy it.

import { Invocation, startInvocation } from "./cli/invocation"

startInvocation()

process.on("unhandledRejection", async (e) => {
  Invocation.abort(e)
  Log.Default.error("rejection", {
    e: e instanceof Error ? e.message : e,
    stack: e instanceof Error ? e.stack : undefined,
  })
  await Invocation.flush()
  process.exit(1)
})

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

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

  • 🟠 packages/cli/src/cli/invocation.ts:23-26 — --format=json detector ignores the `--` separator
  • 🟡 packages/cli/src/cli/invocation.ts:45-50 — flush() may reject; handlers await without try/catch
  • 🟡 packages/cli/src/cli/invocation.ts:66-74 — abort() no-ops after complete(); late rejection misreports
  • 🟡 packages/cli/src/headless.ts:23-34 — Lifecycle wiring duplicated in headless.ts and index.ts
  • 🟠 packages/cli/src/headless.ts:130-132 — INVOCATION_PARSE_ERROR code is unreachable in practice
  • 🟠 packages/cli/src/index.ts:173-175 — INVOCATION_PARSE_ERROR code is unreachable in practice
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #96 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/invocation.ts:23-26 — --format=json detector ignores the `--` separator
   Detail: The JSON-mode detector does `argv.slice(run + 1).some(arg => arg === "--format=json" || (arg === "--format" && next === "json"))` without stopping at the `--` separator that yargs uses to treat all subsequent tokens as positional message text. For an invocation like `aictrl run -- --format=json "do something"`, yargs sets `args.format` to the default (because `--format=json` is a positional after `--`), but the detector still matches the literal and generates an `invocationID`. The result: `invocation_start` and `invocation_complete` are emitted to stdout (because the invocation layer thinks it's in JSON mode), while the run-loop `emit()` in `run.ts` stays silent (because `args.format !== "json"`). Consumers of stdout see a bare invocation envelope with no session events in between, violating the deterministic-envelope goal of the PR. The author thought about positional false-positives for non-`run` subcommands (test: `does not start a run invocation for a positional token on another command`) but not for the `--` separator on `run` itself. The detector also only recognizes two flag spellings, so it can silently diverge from yargs in the other direction for any future alias.
   Suggested fix: Stop the `--format` scan at the first `--` token inside the `.some()` callback:

```ts
argv.slice(run + 1).some((arg, index, args) => {
  if (arg === "--") return false
  if (arg === "--format=json") return true
  return arg === "--format" && args[index + 1] === "json"
})
```

Better long-term: do not re-parse argv at all. Pass the parsed `args.format` value into the invocation context from the run handler so the lifecycle layer's notion of JSON mode is identical to what the run handler actually uses.
2. packages/cli/src/cli/invocation.ts:45-50 — flush() may reject; handlers await without try/catch
   Detail: `emit()` chains writes via `writes = writes.then(() => new Promise((resolve) => process.stdout.write(line, () => resolve())))`. If `process.stdout.write` ever throws synchronously — e.g. stdout was destroyed because a downstream consumer like `head` exited early and closed the pipe — the `new Promise` executor throws, the Promise constructor converts that into a rejection, and the whole `writes` chain rejects with no `.catch()`. `flush()` then returns a rejected promise. The `unhandledRejection` / `uncaughtException` handlers in `headless.ts` and `index.ts` do `await Invocation.flush()` with no try/catch, so the handler itself throws and never reaches `process.exit(1)`, producing a fresh uncaughtException that re-enters the same handler — potentially looping or hanging instead of terminating. The window is narrow (stdout must throw synchronously rather than emit 'error'), but the failure mode is bad: the CLI cannot exit cleanly when its output stream is broken, which is exactly when a consumer is most likely to have closed it.
   Suggested fix: Wrap the write in a try/catch and have the callback's err argument reject explicitly; and wrap `await Invocation.flush()` in the process error handlers with try/catch so a flush failure can never prevent `process.exit(1)`:

```ts
process.on("unhandledRejection", async (e) => {
  Invocation.abort(e)
  Log.Default.error("rejection", { ... })
  try { await Invocation.flush() } catch {}
  process.exit(1)
})
```
3. packages/cli/src/cli/invocation.ts:66-74 — abort() no-ops after complete(); late rejection misreports
   Detail: Both `error()` and `complete()` short-circuit once `completed = true`. The `finally` block in `headless.ts` / `index.ts` runs `Invocation.complete()` → `await Invocation.flush()` → `process.exit()`. If an `unhandledRejection` (or `uncaughtException`) arrives during the `await Invocation.flush()` window — which is a real async gap where the event loop runs — the rejection handler calls `Invocation.abort(e)`, but `error()` returns early (`completed === true`) and `complete()` is also a no-op. The NDJSON stream therefore already carries `invocation_complete.status = "completed"` from the happy path, while the process actually exits with code 1 from the rejection handler. Consumers trusting the lifecycle envelope as the source of truth for invocation result get a false success in the exact error path they most need to trust. The window is narrow but reachable (any fire-and-forget promise that rejects during the flush drain will hit it).
   Suggested fix: Track a `finalized` flag separately from the `completed` flag, or have `abort()` emit a corrective event when an error arrives after completion — e.g. re-emit (or revise) `invocation_complete` with `status: "error"`, or emit a dedicated `invocation_error` so the lifecycle stream always agrees with the real exit code. At minimum, document this edge case in EVENTS.md so consumers know that a late rejection after `invocation_complete` can still flip the process exit code.
4. packages/cli/src/headless.ts:23-34 — Lifecycle wiring duplicated in headless.ts and index.ts
   Detail: This PR adds the same ~15 lines of Invocation lifecycle wiring to BOTH `headless.ts` and `index.ts`: the `import { Invocation, startInvocation }` line, the top-level `startInvocation()` call, the `Invocation.abort(e)` + `await Invocation.flush()` additions inside both `unhandledRejection` and `uncaughtException` handlers, the `Invocation.abort(..., "INVOCATION_PARSE_ERROR")` + `if (Invocation.id) throw new Error(...)` block in the yargs `.fail` handler, the `Invocation.error(e)` line in `catch`, and the `Invocation.complete()` + `await Invocation.flush()` pair in `finally`. The two files were already near-duplicates; this PR extends the byte-for-byte duplication. Any future fix to the lifecycle wiring (e.g. the INVOCATION_PARSE_ERROR bug also raised in this review) must be applied to both files in lockstep, or the two entry points will drift. The project convention (per the consistency checklist) is to move cross-file behaviour into a shared module rather than copy it.
   Suggested fix: Extract the process-handler registrations, the yargs `.fail` Invocation hook, and the try/catch/finally Invocation cleanup into a single shared function exported from `invocation.ts` (e.g. `registerInvocationLifecycle(cli)`), and call it once from each entry point. The two INVOCATION_PARSE_ERROR bugs raised elsewhere in this review are a concrete example of the maintenance cost this duplication creates.
5. packages/cli/src/headless.ts:130-132 — INVOCATION_PARSE_ERROR code is unreachable in practice
   Detail: In the yargs `.fail()` handler, `if (err) throw err` on line 130 runs BEFORE `Invocation.abort(msg, "INVOCATION_PARSE_ERROR")` on line 131. Yargs passes a truthy `err` object alongside `msg` for every parse-error case the handler checks ("Unknown argument", "Invalid values:", etc.), so the throw fires first and control transfers to the `catch (e)` block around `await cli.parse()`. That catch calls `Invocation.error(e)` with the DEFAULT code `INVOCATION_FAILED`. As a result, the documented `INVOCATION_PARSE_ERROR` code is dead code in the common parse-error case — the emitted `invocation_error.code` is `INVOCATION_FAILED` instead. The test `reports argument parsing failure` passes only because `expectFailure` asserts on `error.phase` and `error.message` but never on `error.code`, so this regression is not caught. Any downstream consumer branching on `code === "INVOCATION_PARSE_ERROR"` will never match. The identical bug exists in `packages/cli/src/index.ts` (same handler around line 173).
   Suggested fix: Call `Invocation.abort(...)` BEFORE the `if (err) throw err`, or set the code on the error before rethrowing. Example:

```ts
.fail((msg, err) => {
  const isParseError =
    msg?.startsWith("Unknown argument") || msg?.startsWith("Invalid values:")
  if (isParseError) {
    Invocation.abort(msg ?? "Invalid command line arguments", "INVOCATION_PARSE_ERROR")
    if (err) throw err
    if (!Invocation.id) cli.showHelp("log")
    if (Invocation.id) throw new Error(msg ?? "Invalid command line arguments")
    process.exit(1)
  }
  if (err) throw err
  process.exit(1)
})
```

Apply the same fix to `packages/cli/src/index.ts`. Also add an assertion on `error.code` in the `reports argument parsing failure` test so this does not regress.
6. packages/cli/src/index.ts:173-175 — INVOCATION_PARSE_ERROR code is unreachable in practice
   Detail: Same bug as in `packages/cli/src/headless.ts`: in the yargs `.fail()` handler, `if (err) throw err` on line 173 runs BEFORE `Invocation.abort(msg, "INVOCATION_PARSE_ERROR")` on line 174. Yargs passes a truthy `err` alongside `msg` for every parse-error case ("Unknown argument", "Invalid values:"), so the throw fires first and control transfers to the `catch (e)` block. That catch calls `Invocation.error(e)` with the DEFAULT code `INVOCATION_FAILED`. The documented `INVOCATION_PARSE_ERROR` code is therefore dead code for the common parse-error path, and consumers branching on `code === "INVOCATION_PARSE_ERROR"` will never match.
   Suggested fix: Call `Invocation.abort(..., "INVOCATION_PARSE_ERROR")` BEFORE `if (err) throw err`, so the code is set on the invocation before the throw propagates to the catch block. Apply the same fix to `packages/cli/src/headless.ts`.
📋 Out-of-diff findings (6)
Sev Location Finding
🟠 packages/cli/src/cli/invocation.ts:23-26 --format=json detector ignores the `--` separator
🟡 packages/cli/src/cli/invocation.ts:45-50 flush() may reject; handlers await without try/catch
🟡 packages/cli/src/cli/invocation.ts:66-74 abort() no-ops after complete(); late rejection misreports
🟡 packages/cli/src/headless.ts:23-34 Lifecycle wiring duplicated in headless.ts and index.ts
🟠 packages/cli/src/headless.ts:130-132 INVOCATION_PARSE_ERROR code is unreachable in practice
🟠 packages/cli/src/index.ts:173-175 INVOCATION_PARSE_ERROR code is unreachable in practice

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
Finding Verdict / action Resolution and evidence Verification
JSON detector crosses -- TRUE · FIX Format scanning now stops at the argument separator, matching yargs positional semantics. Regression runs run --dir <missing> -- --format=json prompt and observes no invocation NDJSON.
Flush rejection can defeat process exit TRUE · FIX Lifecycle write-chain failures are absorbed, and Invocation.drain() gives process handlers an explicitly non-throwing drain before forced exit. Isolated child replaces stdout.write with a synchronous throw; drain resolves and the child exits 0.
Late rejection after completion TRUE · FIX Finalizers now drain pre-terminal output before creating the sole terminal event. An error arriving in that async window can mark the invocation failed and create exactly one error completion; no corrective duplicate is emitted. Once a terminal event is written it remains immutable, preserving the documented one-terminal-event contract. Delayed-write regression injects an abort during the pre-terminal drain and observes one invocation_complete with status: error.
Entry-point lifecycle duplication TRUE · IGNORE The calls remain coupled to pre-existing distinct process/yargs/catch/finally scaffolding. Both files received the same bounded behavior fix; extracting all ownership is architectural work without a separate observed defect, so no follow-up is warranted here. Both entrypoints are exercised by focused parse tests; exception-handler tests cover both process handlers.
Headless parse-error code unreachable FALSE · IGNORE Real headless.ts subprocesses emit INVOCATION_PARSE_ERROR for unknown arguments and invalid choices. Yargs usage-validation paths pass no truthy err; truthy handler errors correctly bypass parse classification. Focused test now asserts the exact code.
Main entrypoint parse-error code unreachable FALSE · IGNORE Real index.ts subprocess output also emits INVOCATION_PARSE_ERROR; the claimed early truthy-err branch is not taken for yargs parse validation. The same focused test runs against both entrypoints and asserts the exact code.

Remediation commit: 7391603b1.

Verification:

  • bun test test/cli/invocation-lifecycle.test.ts --timeout 30000 — PASS (14 tests, 137 assertions)
  • bun test test/cli/exception-handler.test.ts test/cli/invocation-lifecycle.test.ts --timeout 30000 — PASS (19 tests, 142 assertions)
  • bun test --timeout 30000 — PASS (1,338 passed, 7 skipped, 0 failed; 3,157 assertions)
  • bun run typecheck — PASS
  • push-hook workspace typecheck — PASS (6/6 tasks)

This user-triggered remediation round is bounded; no further automated review loop is being requested.

@byapparov byapparov self-assigned this Jul 19, 2026
@byapparov
byapparov force-pushed the feat/issue-90-invocation-lifecycle branch from 7391603 to 9928593 Compare July 20, 2026 07:43
@byapparov
byapparov force-pushed the feat/issue-90-invocation-lifecycle branch from 9928593 to 2835d9a Compare July 20, 2026 18:29
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.

Add NDJSON invocation lifecycle events before session creation

1 participant