Skip to content

fix(cli): flush NDJSON before forced exit#95

Merged
byapparov merged 4 commits into
mainfrom
fix/issue-91-ndjson-flush
Jul 20, 2026
Merged

fix(cli): flush NDJSON before forced exit#95
byapparov merged 4 commits into
mainfrom
fix/issue-91-ndjson-flush

Conversation

@byapparov

@byapparov byapparov commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #91

Intent

Make machine-readable NDJSON output durable through the CLI's forced-exit path without changing event meaning or ordering.

Expected outcomes

  • Every NDJSON record accepted by the writer, including the terminal record, is handed off before forced exit.
  • Large piped outputs respect backpressure instead of being truncated.
  • A downstream consumer closing the pipe is treated as a controlled EPIPE condition.
  • Both CLI entrypoints use the same explicit flush barrier.

Expected impact

Automation and CI consumers can parse complete streams reliably instead of receiving partial JSON or losing the final status. Interactive and non-NDJSON behavior remains unchanged, and this establishes the stdout-delivery foundation used by the invocation and signal workstreams.

Summary

  • serialize headless NDJSON writes through one stdout writer
  • pause the writer queue when stdout reports backpressure and provide an awaited flush barrier
  • flush after command/instance disposal and before the existing forced exit in both CLI entry points
  • treat EPIPE from a closed downstream consumer as a controlled condition

Reproduction

A Bun 1.3.10 child that immediately forced-exited after writing ~2.1 MiB to a pipe delivered 621,248 bytes and lost its terminal record. The regression test uses a real child process and verifies all 1,025 NDJSON records, including the terminal record, arrive before forced exit.

This change only addresses stdout durability. It does not claim to fix independent event-delivery ordering races.

Verification

  • cd packages/cli && bun test test/cli/stdout.test.ts — 2 passed
  • cd packages/cli && bun run typecheck — passed
  • cd packages/cli && bun test — 1,326 passed, 7 skipped
  • pre-push workspace typecheck — 6 tasks passed
  • git diff --check — passed

return state.queue
}

export async 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.

🟠 Signal exits bypass flush(); NDJSON still truncates.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/src/cli/stdout.ts:39-42):

Problem: Signal exits bypass flush(); NDJSON still truncates
Detail: `Stdout.flush()` is invoked only from the `try/finally` blocks of `index.ts` and `headless.ts`. SIGINT / SIGTERM / SIGHUP terminate the Node process abruptly — `finally` does not run for signal-induced exits — so any writes still chained in `state.queue` when a signal arrives are dropped. The PR diff contains no `SIGINT`/`SIGTERM`/`SIGHUP`/`beforeExit` handler that would drain the queue, so for the common case of `aictrl run --format=json | jq …` killed via Ctrl+C, or a supervisor sending SIGTERM, the lifecycle/error NDJSON events still sitting in `state.queue` are lost — the same truncation issue #91 was opened for. The PR statement ("fully flushed before process.exit() runs, even under pipe backpressure or EPIPE") is satisfied only for the happy path; forced exits via signal regress to pre-fix behavior. If signal handlers exist elsewhere in the codebase, this is moot, but the diff itself does not register any.
Suggested fix: In the Stdout module, register `process.once(sig, async () => { try { await flush() } finally { process.exit(sig === 'SIGINT' ? 130 : 143) } })` for SIGINT/SIGTERM/SIGHUP, and/or drain in a `process.on('beforeExit')` handler. Confirm whether the rest of the codebase already registers signal handlers that route through `process.exit()` (in which case finally covers them).

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

Stdout.flush() is invoked only from the try/finally blocks of index.ts and headless.ts. SIGINT / SIGTERM / SIGHUP terminate the Node process abruptly — finally does not run for signal-induced exits — so any writes still chained in state.queue when a signal arrives are dropped. The PR diff contains no SIGINT/SIGTERM/SIGHUP/beforeExit handler that would drain the queue, so for the common case of aictrl run --format=json | jq … killed via Ctrl+C, or a supervisor sending SIGTERM, the lifecycle/error NDJSON events still sitting in state.queue are lost — the same truncation issue #91 was opened for. The PR statement ("fully flushed before process.exit() runs, even under pipe backpressure or EPIPE") is satisfied only for the happy path; forced exits via signal regress to pre-fix behavior. If signal handlers exist elsewhere in the codebase, this is moot, but the diff itself does not register any.

export namespace Stdout {
  export function write(chunk: string) {
    state.queue = state.queue.then(() => output(chunk))
    return state.queue
  }

  export async function flush() {
    await state.queue
    await output("", true)
  }
}

Comment thread packages/cli/src/cli/stdout.ts Outdated
})
}

process.stdout.on("error", (error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Global stdout error handler installed at import.

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

Problem: Global stdout error handler installed at import
Detail: `process.stdout.on("error", ...)` is registered at module load as an import side-effect. Because `Stdout` is imported by `run.ts`, `headless.ts`, AND `index.ts`, the listener attaches once but governs ALL stdout writes process-wide — including interactive / text / non-JSON modes that never go through `Stdout`. This silently changes error semantics for unrelated code paths: EPIPE on stdout is silently swallowed everywhere (state.closed = true), and non-EPIPE errors are re-thrown from inside an EventEmitter error handler, which surfaces as an uncaughtException from an unpredictable call site rather than the default stream-error path. Neighbouring utility modules in this codebase (e.g. `Locale`, `Database`) are pure exports without global hook registration at import. Recommend scoping the handler to the JSON/NDJSON path — either via an explicit `Stdout.init()` called only from the JSON-format code path, or by attaching lazily on first `write()` guarded by a `state.bound` flag.
Suggested fix: Remove the module-level `process.stdout.on("error", ...)`. Attach it lazily on first `write()` (guarded by a `state.bound` flag) so only processes that actually use `Stdout` get the global handler, leaving interactive / text mode untouched.

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

process.stdout.on("error", ...) is registered at module load as an import side-effect. Because Stdout is imported by run.ts, headless.ts, AND index.ts, the listener attaches once but governs ALL stdout writes process-wide — including interactive / text / non-JSON modes that never go through Stdout. This silently changes error semantics for unrelated code paths: EPIPE on stdout is silently swallowed everywhere (state.closed = true), and non-EPIPE errors are re-thrown from inside an EventEmitter error handler, which surfaces as an uncaughtException from an unpredictable call site rather than the default stream-error path. Neighbouring utility modules in this codebase (e.g. Locale, Database) are pure exports without global hook registration at import. Recommend scoping the handler to the JSON/NDJSON path — either via an explicit Stdout.init() called only from the JSON-format code path, or by attaching lazily on first write() guarded by a state.bound flag.

process.stdout.on("error", (error) => {
  if (pipe(error)) {
    state.closed = true
    return
  }
  throw error
})

Comment thread packages/cli/src/cli/stdout.ts Outdated
return new Promise<void>((resolve) => {
const drain = () => resolve()
const wait = { ready: true, done: false }
wait.ready = process.stdout.write(chunk, (error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Inconsistent error handling within stdout.ts.

Suggested change
wait.ready = process.stdout.write(chunk, (error) => {
In the write callback, reject the Promise on non-EPIPE errors (`if (error && !pipe(error)) reject(error); else { state.closed = true; resolve() }`) so the failure style matches the global handler's throw-on-non-EPIPE intent, and so callers of `flush()` can distinguish success from silent close.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/src/cli/stdout.ts:15-31):

Problem: Inconsistent error handling within stdout.ts
Detail: Within the same module, errors on the same stream are handled two different ways. The `process.stdout.write` callback inside `output()` swallows ALL errors (`if (error) state.closed = true; resolve()`) — never rejecting the Promise. The module-level `error` listener, by contrast, re-throws non-EPIPE errors. Both code paths field the same class of failures on the same stream, so callers cannot tell a successful flush from a silently-closed pipe; the only path that surfaces a failure is the global listener's `throw`, and only for non-EPIPE. Pick one style: either propagate via Promise rejection (so callers can react), or swallow uniformly.
Suggested fix: In the write callback, reject the Promise on non-EPIPE errors (`if (error && !pipe(error)) reject(error); else { state.closed = true; resolve() }`) so the failure style matches the global handler's throw-on-non-EPIPE intent, and so callers of `flush()` can distinguish success from silent close.

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

Within the same module, errors on the same stream are handled two different ways. The process.stdout.write callback inside output() swallows ALL errors (if (error) state.closed = true; resolve()) — never rejecting the Promise. The module-level error listener, by contrast, re-throws non-EPIPE errors. Both code paths field the same class of failures on the same stream, so callers cannot tell a successful flush from a silently-closed pipe; the only path that surfaces a failure is the global listener's throw, and only for non-EPIPE. Pick one style: either propagate via Promise rejection (so callers can react), or swallow uniformly.

    wait.ready = process.stdout.write(chunk, (error) => {
      wait.done = true
      if (error) state.closed = true
      if (!wait.ready) process.stdout.off("drain", drain)
      resolve()
    })

Comment thread packages/cli/src/cli/stdout.ts Outdated
return error instanceof Error && "code" in error && error.code === "EPIPE"
}

function output(chunk: string, flush = false) {

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 flag is dead code; trailing empty write is a no-op.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/src/cli/stdout.ts:10-42):

Problem: flush flag is dead code; trailing empty write is a no-op
Detail: Two related dead-code issues in `flush()`/`output()`: (1) The `flush` parameter on `output(chunk, flush = false)` exists solely to skip registering a `drain` listener under backpressure, but the only call site that passes `flush=true` is `flush()` → `output("", true)`, and an empty-string chunk never triggers backpressure on `process.stdout.write`, so `wait.ready` is always `true` and the `!flush` branch is never reached differently. The flag is effectively dead at its current call site. (2) The trailing `await output("", true)` in `flush()` adds no drain guarantee beyond what `await state.queue` already provides: per Node's Writable contract, an empty-string write is a no-op at the OS level and its callback fires regardless of buffer state. The construct is misleading — future readers will assume the trailing empty write is what guarantees OS-level flush and "fix" it incorrectly. `await state.queue` already awaits every prior write's per-chunk callback (which fires after libuv hands the bytes to the OS), so the kernel pipe buffer already holds everything before `flush()` returns.
Suggested fix: Drop the `flush` parameter (`function output(chunk: string) { ... }`) and drop the trailing `await output("", true)`. Rely solely on `await state.queue`. If a final-yield semantic is desired, replace it with an explicit `await new Promise<void>(r => process.stdout.write("", () => r()))` with a comment noting it only yields one tick.

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

Two related dead-code issues in flush()/output(): (1) The flush parameter on output(chunk, flush = false) exists solely to skip registering a drain listener under backpressure, but the only call site that passes flush=true is flush()output("", true), and an empty-string chunk never triggers backpressure on process.stdout.write, so wait.ready is always true and the !flush branch is never reached differently. The flag is effectively dead at its current call site. (2) The trailing await output("", true) in flush() adds no drain guarantee beyond what await state.queue already provides: per Node's Writable contract, an empty-string write is a no-op at the OS level and its callback fires regardless of buffer state. The construct is misleading — future readers will assume the trailing empty write is what guarantees OS-level flush and "fix" it incorrectly. await state.queue already awaits every prior write's per-chunk callback (which fires after libuv hands the bytes to the OS), so the kernel pipe buffer already holds everything before flush() returns.

function output(chunk: string, flush = false) {
  if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve()
  return new Promise<void>((resolve) => {
    const drain = () => resolve()
    const wait = { ready: true, done: false }
    wait.ready = process.stdout.write(chunk, (error) => {
      wait.done = true
      if (error) state.closed = true
      if (!wait.ready) process.stdout.off("drain", drain)
      resolve()
    })
    if (!wait.ready && !wait.done && !flush) process.stdout.once("drain", drain)
  })
}

})

export namespace Stdout {
export function write(chunk: 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.

🟡 state.queue grows unbounded under backpressure.

Suggested change
export function write(chunk: string) {
Bound the queue: track pending bytes/chunks; when above a high watermark, either await a drain slot before queueing more, or expose `write()` as async and have `emit()`/the agent loop await it so backpressure propagates to the producer.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/src/cli/stdout.ts:34-36):

Problem: state.queue grows unbounded under backpressure
Detail: `Stdout.write()` reassigns `state.queue = state.queue.then(() => output(chunk))` on every call; each `.then` closure captures `chunk`. `output()` is fully serialized (one in-flight write at a time), so when the downstream pipe applies backpressure (slow consumer, e.g. `aictrl run --format=json | pv -L 1k`), writes accumulate as pending closures in `state.queue`, each retaining its full chunk string. There is no high-watermark, no drop policy, and no backpressure signal back to `emit()` callers — `emit()` returns `true` synchronously and the producer keeps calling. Long runs against a slow pipe can grow memory without bound before `flush()` runs. The previous direct `process.stdout.write` had the same fundamental issue but resided in libuv's buffer; this PR duplicates the retention in a JS Promise chain on top.
Suggested fix: Bound the queue: track pending bytes/chunks; when above a high watermark, either await a drain slot before queueing more, or expose `write()` as async and have `emit()`/the agent loop await it so backpressure propagates to the producer.

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

Stdout.write() reassigns state.queue = state.queue.then(() => output(chunk)) on every call; each .then closure captures chunk. output() is fully serialized (one in-flight write at a time), so when the downstream pipe applies backpressure (slow consumer, e.g. aictrl run --format=json | pv -L 1k), writes accumulate as pending closures in state.queue, each retaining its full chunk string. There is no high-watermark, no drop policy, and no backpressure signal back to emit() callers — emit() returns true synchronously and the producer keeps calling. Long runs against a slow pipe can grow memory without bound before flush() runs. The previous direct process.stdout.write had the same fundamental issue but resided in libuv's buffer; this PR duplicates the retention in a JS Promise chain on top.

  export function write(chunk: string) {
    state.queue = state.queue.then(() => output(chunk))
    return state.queue
  }

Comment thread packages/cli/test/cli/stdout.test.ts
Comment thread packages/cli/src/cli/stdout.ts Outdated
@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

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

  • packages/cli/src/cli/stdout.ts:6-15 — pipe() name reads as noun; wait.ready init misleading
  • 🟡 packages/cli/src/cli/stdout.ts:10-42 — flush flag is dead code; trailing empty write is a no-op
  • 🟡 packages/cli/src/cli/stdout.ts:15-31 — Inconsistent error handling within stdout.ts
  • 🟠 packages/cli/src/cli/stdout.ts:25-31 — Global stdout error handler installed at import
  • 🟡 packages/cli/src/cli/stdout.ts:34-36 — state.queue grows unbounded under backpressure
  • 🟠 packages/cli/src/cli/stdout.ts:39-42 — Signal exits bypass flush(); NDJSON still truncates
  • 🟡 packages/cli/test/cli/stdout.test.ts:30-37 — EPIPE test assertions too weak to lock the code path
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #95 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/stdout.ts:6-15 — pipe() name reads as noun; wait.ready init misleading
   Detail: Two readability nits. (1) `function pipe(error: unknown)` reads as a noun (a pipe) rather than a predicate; neighbour predicate style in this codebase favours `isX`/`hasX` for boolean-returning guards. `isEpipeError(error)` would convey intent. (2) `const wait = { ready: true, done: false }` initializes `ready: true` and then immediately overwrites it on the next line with `wait.ready = process.stdout.write(...)`. The initial `true` is misleading dead state, made more confusing by the in-callback check `if (!wait.ready)` which depends on whether the assignment has run. Initialize with the actual value or declare the field and assign once.
   Suggested fix: Rename to `function isEpipeError(error: unknown): boolean { return error instanceof Error && (error as NodeJS.ErrnoException).code === "EPIPE" }`, and drop the `wait.ready = true` initializer: `const wait = { done: false }; wait.ready = process.stdout.write(...)`.
2. packages/cli/src/cli/stdout.ts:10-42 — flush flag is dead code; trailing empty write is a no-op
   Detail: Two related dead-code issues in `flush()`/`output()`: (1) The `flush` parameter on `output(chunk, flush = false)` exists solely to skip registering a `drain` listener under backpressure, but the only call site that passes `flush=true` is `flush()` → `output("", true)`, and an empty-string chunk never triggers backpressure on `process.stdout.write`, so `wait.ready` is always `true` and the `!flush` branch is never reached differently. The flag is effectively dead at its current call site. (2) The trailing `await output("", true)` in `flush()` adds no drain guarantee beyond what `await state.queue` already provides: per Node's Writable contract, an empty-string write is a no-op at the OS level and its callback fires regardless of buffer state. The construct is misleading — future readers will assume the trailing empty write is what guarantees OS-level flush and "fix" it incorrectly. `await state.queue` already awaits every prior write's per-chunk callback (which fires after libuv hands the bytes to the OS), so the kernel pipe buffer already holds everything before `flush()` returns.
   Suggested fix: Drop the `flush` parameter (`function output(chunk: string) { ... }`) and drop the trailing `await output("", true)`. Rely solely on `await state.queue`. If a final-yield semantic is desired, replace it with an explicit `await new Promise<void>(r => process.stdout.write("", () => r()))` with a comment noting it only yields one tick.
3. packages/cli/src/cli/stdout.ts:15-31 — Inconsistent error handling within stdout.ts
   Detail: Within the same module, errors on the same stream are handled two different ways. The `process.stdout.write` callback inside `output()` swallows ALL errors (`if (error) state.closed = true; resolve()`) — never rejecting the Promise. The module-level `error` listener, by contrast, re-throws non-EPIPE errors. Both code paths field the same class of failures on the same stream, so callers cannot tell a successful flush from a silently-closed pipe; the only path that surfaces a failure is the global listener's `throw`, and only for non-EPIPE. Pick one style: either propagate via Promise rejection (so callers can react), or swallow uniformly.
   Suggested fix: In the write callback, reject the Promise on non-EPIPE errors (`if (error && !pipe(error)) reject(error); else { state.closed = true; resolve() }`) so the failure style matches the global handler's throw-on-non-EPIPE intent, and so callers of `flush()` can distinguish success from silent close.
4. packages/cli/src/cli/stdout.ts:25-31 — Global stdout error handler installed at import
   Detail: `process.stdout.on("error", ...)` is registered at module load as an import side-effect. Because `Stdout` is imported by `run.ts`, `headless.ts`, AND `index.ts`, the listener attaches once but governs ALL stdout writes process-wide — including interactive / text / non-JSON modes that never go through `Stdout`. This silently changes error semantics for unrelated code paths: EPIPE on stdout is silently swallowed everywhere (state.closed = true), and non-EPIPE errors are re-thrown from inside an EventEmitter error handler, which surfaces as an uncaughtException from an unpredictable call site rather than the default stream-error path. Neighbouring utility modules in this codebase (e.g. `Locale`, `Database`) are pure exports without global hook registration at import. Recommend scoping the handler to the JSON/NDJSON path — either via an explicit `Stdout.init()` called only from the JSON-format code path, or by attaching lazily on first `write()` guarded by a `state.bound` flag.
   Suggested fix: Remove the module-level `process.stdout.on("error", ...)`. Attach it lazily on first `write()` (guarded by a `state.bound` flag) so only processes that actually use `Stdout` get the global handler, leaving interactive / text mode untouched.
5. packages/cli/src/cli/stdout.ts:34-36 — state.queue grows unbounded under backpressure
   Detail: `Stdout.write()` reassigns `state.queue = state.queue.then(() => output(chunk))` on every call; each `.then` closure captures `chunk`. `output()` is fully serialized (one in-flight write at a time), so when the downstream pipe applies backpressure (slow consumer, e.g. `aictrl run --format=json | pv -L 1k`), writes accumulate as pending closures in `state.queue`, each retaining its full chunk string. There is no high-watermark, no drop policy, and no backpressure signal back to `emit()` callers — `emit()` returns `true` synchronously and the producer keeps calling. Long runs against a slow pipe can grow memory without bound before `flush()` runs. The previous direct `process.stdout.write` had the same fundamental issue but resided in libuv's buffer; this PR duplicates the retention in a JS Promise chain on top.
   Suggested fix: Bound the queue: track pending bytes/chunks; when above a high watermark, either await a drain slot before queueing more, or expose `write()` as async and have `emit()`/the agent loop await it so backpressure propagates to the producer.
6. packages/cli/src/cli/stdout.ts:39-42 — Signal exits bypass flush(); NDJSON still truncates
   Detail: `Stdout.flush()` is invoked only from the `try/finally` blocks of `index.ts` and `headless.ts`. SIGINT / SIGTERM / SIGHUP terminate the Node process abruptly — `finally` does not run for signal-induced exits — so any writes still chained in `state.queue` when a signal arrives are dropped. The PR diff contains no `SIGINT`/`SIGTERM`/`SIGHUP`/`beforeExit` handler that would drain the queue, so for the common case of `aictrl run --format=json | jq …` killed via Ctrl+C, or a supervisor sending SIGTERM, the lifecycle/error NDJSON events still sitting in `state.queue` are lost — the same truncation issue #91 was opened for. The PR statement ("fully flushed before process.exit() runs, even under pipe backpressure or EPIPE") is satisfied only for the happy path; forced exits via signal regress to pre-fix behavior. If signal handlers exist elsewhere in the codebase, this is moot, but the diff itself does not register any.
   Suggested fix: In the Stdout module, register `process.once(sig, async () => { try { await flush() } finally { process.exit(sig === 'SIGINT' ? 130 : 143) } })` for SIGINT/SIGTERM/SIGHUP, and/or drain in a `process.on('beforeExit')` handler. Confirm whether the rest of the codebase already registers signal handlers that route through `process.exit()` (in which case finally covers them).
7. packages/cli/test/cli/stdout.test.ts:30-37 — EPIPE test assertions too weak to lock the code path
   Detail: The "treats a closed output pipe as controlled" test asserts only `expect(await child.exited).toBe(0)` and empty stderr after `await child.stdout.cancel()`. It does not assert that an EPIPE actually occurred (e.g. by checking fewer than 8192 records were consumed, or that `state.closed` was reached). If Bun's `ReadableStream.cancel()` does not close the underlying pipe fd in some runtime version, the child would simply block on backpressure forever (test would hang, not fail informatively); conversely a passing test does not prove the EPIPE branch in `stdout.ts` was exercised. The implementation could regress (e.g. remove the `pipe(error)` EPIPE branch in the `process.stdout.on("error")` handler) and the test would still pass as long as Bun's `cancel()` keeps the child from crashing — the write-callback error path would set `state.closed` instead. The test name promises verification of the closed-pipe (EPIPE) code path; the assertions don't lock that in.
   Suggested fix: Make the EPIPE path observable: have the fixture emit a final `{type:"closed"}` record from inside the `process.stdout.on("error")` handler (or after detecting `state.closed`), and assert that line is present; or assert the parent observed fewer than 8193 lines (proving writes were dropped after EPIPE). As written, the assertions are also satisfied by a healthy run that never triggered EPIPE.
📋 Out-of-diff findings (7)
Sev Location Finding
packages/cli/src/cli/stdout.ts:6-15 pipe() name reads as noun; wait.ready init misleading
🟡 packages/cli/src/cli/stdout.ts:10-42 flush flag is dead code; trailing empty write is a no-op
🟡 packages/cli/src/cli/stdout.ts:15-31 Inconsistent error handling within stdout.ts
🟠 packages/cli/src/cli/stdout.ts:25-31 Global stdout error handler installed at import
🟡 packages/cli/src/cli/stdout.ts:34-36 state.queue grows unbounded under backpressure
🟠 packages/cli/src/cli/stdout.ts:39-42 Signal exits bypass flush(); NDJSON still truncates
🟡 packages/cli/test/cli/stdout.test.ts:30-37 EPIPE test assertions too weak to lock the code path

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

Addressed the current-head review against e8a7a143e0812d500f7f877329bbcca85b4ba8f3.

Finding Verdict / action Resolution Verification
r3611131555 predicate/readiness state TRUE · FIX Renamed the EPIPE predicate and removed the readiness state entirely by using Writable completion callbacks as the drain contract. bun test test/cli/stdout.test.ts — PASS (4/4)
r3611131550 dead flush flag/empty write TRUE · FIX Removed both the flag and trailing empty write; flush() now waits for every outstanding write callback. Multi-megabyte forced-exit regression — PASS
r3611131547 inconsistent errors TRUE · FIX EPIPE remains controlled; other stream errors are retained and surfaced by flush() rather than thrown from an EventEmitter callback or silently swallowed. Dedicated injected-EIO subprocess regression — PASS
r3611131545 import-time global listener TRUE · FIX Error handling binds lazily on the first managed write. Importing Stdout no longer changes process-wide stdout behavior. Dedicated listener-count subprocess regression — PASS
r3611131551 JavaScript queue retention TRUE · FIX Writes now enter the Writable immediately and only callback promises are tracked; the serialized closures that retained every full chunk were removed. Native Writable buffering remains the pre-existing synchronous-emitter contract, without a second JS payload queue. 2+ MiB delivery regression and full suite — PASS
r3611131543 signal ownership TRUE · DEFER Signal cancellation is issue #94 / PR #98. Its current head owns SIGINT/SIGTERM handling, terminal events, output drain, grace timeout, and exit codes. Adding competing signal handlers here would make shutdown order-dependent; #95 stays scoped to the drain primitive used by normal/forced CLI exits. Independently inspected PR #98 head 8b7107d50; integration validation will cover both PRs.
r3611131554 EPIPE observability TRUE · FIX The regression now uses a real shell pipe closed by head, and the child exits successfully only if Stdout.closed() observed EPIPE. Dedicated OS-level broken-pipe subprocess regression — PASS

Verification on this head:

  • bun test test/cli/stdout.test.ts — 4 pass, 0 fail
  • bun test --timeout 30000 — 1328 pass, 7 skip, 0 fail
  • bun run typecheck — PASS
  • repository pre-push bun turbo typecheck — 6/6 tasks successful

Requesting one bounded re-review of the updated head. No merge requested.

Comment thread packages/cli/src/headless.ts Outdated
Comment thread packages/cli/src/index.ts Outdated
return pending
}

export async 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() throwing makes it unsafe for finally blocks.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/src/cli/stdout.ts:41-44):

Problem: flush() throwing makes it unsafe for finally blocks
Detail: flush() is intended to be awaited in the finally of both entrypoints before process.exit(). By throwing `state.error` it forces every caller in a shutdown path to remember a try/catch, and the current callers (index.ts, headless.ts) do NOT wrap it — so the throw propagates and skips process.exit() (see the two MAJOR findings). Either swallow inside flush and expose the error via a getter (e.g. `Stdout.error()`) for callers that care, or document on flush() that it must be wrapped on shutdown paths. As-is, the API is a foot-gun the only two callers in the repo have already stepped on.
Suggested fix: Option A (consolidate at source): `export async function flush() { while (state.pending.size) await Promise.all(state.pending) }` and add `export function error() { return state.error }`. Option B (keep throw but document): add a JSDoc note that flush() throws on prior write errors and must be wrapped when used before process.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

flush() is intended to be awaited in the finally of both entrypoints before process.exit(). By throwing state.error it forces every caller in a shutdown path to remember a try/catch, and the current callers (index.ts, headless.ts) do NOT wrap it — so the throw propagates and skips process.exit() (see the two MAJOR findings). Either swallow inside flush and expose the error via a getter (e.g. Stdout.error()) for callers that care, or document on flush() that it must be wrapped on shutdown paths. As-is, the API is a foot-gun the only two callers in the repo have already stepped on.

  export async function flush() {
    while (state.pending.size) await Promise.all(state.pending)
    if (state.error) throw state.error
  }

}

export namespace Stdout {
export function write(chunk: 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.

🟡 write() never rejects while flush() throws.

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

Problem: write() never rejects while flush() throws
Detail: Stdout.write returns a Promise<void> that ALWAYS resolves: the write callback stores any error into state.error via fail() and then resolves unconditionally. Errors only surface when flush() is later called and throws. This is asymmetric with flush()'s throw-on-error contract and breaks the usual Promise<void> intuition — callers who `await Stdout.write(...)` expecting it to reject on I/O failure will silently miss errors. The one caller in run.ts (emit()) does not await the returned promise, so the contract is currently load-bearing on remembering to call flush().
Suggested fix: Either document the contract on write() (e.g. `// Resolves regardless; errors are surfaced by flush()`), or have write() reject when state.error is already set so individual awaits can observe prior 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

Stdout.write returns a Promise that ALWAYS resolves: the write callback stores any error into state.error via fail() and then resolves unconditionally. Errors only surface when flush() is later called and throws. This is asymmetric with flush()'s throw-on-error contract and breaks the usual Promise intuition — callers who await Stdout.write(...) expecting it to reject on I/O failure will silently miss errors. The one caller in run.ts (emit()) does not await the returned promise, so the contract is currently load-bearing on remembering to call flush().

  export function write(chunk: string) {
    bind()
    if (state.closed || process.stdout.destroyed || process.stdout.writableEnded) return Promise.resolve()
    const pending = new Promise<void>((resolve) => {
      process.stdout.write(chunk, (error) => {
        if (error) fail(error)
        resolve()
      })
    })
    state.pending.add(pending)
    pending.then(() => state.pending.delete(pending))
    return pending
  }

const lines = (await output)
.trim()
.split("\n")
.map((line) => JSON.parse(line))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 EPIPE test shells out to bash+head, unlike sibling tests.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/test/cli/stdout.test.ts:20-35):

Problem: EPIPE test shells out to bash+head, unlike sibling tests
Detail: The "treats a closed output pipe as controlled" test wraps the fixture in `bash -c '... | head -c 1'`, while the three other tests in the same file spawn the fixture directly via `Bun.spawn` with no shell. This is an internal style inconsistency and adds a POSIX-only dependency (bash, head) that will fail on Windows CI or minimal containers. The same scenario (read one byte, then close the consumer) can be expressed with `Bun.spawn({ stdout: "pipe" })` + `cli.stdout.getReader()` + `reader.read()` + `reader.cancel()`, keeping the file shell-free.
Suggested fix: Replace the bash wrapper with a direct Bun spawn whose stdout is read for 1 byte then cancelled:
```ts
const cli = Bun.spawn([process.execPath, fixturePath, "--epipe"], { stdout: "pipe", stderr: "pipe" });
const reader = cli.stdout.getReader();
await reader.read();
await reader.cancel();
expect(await cli.exited).toBe(0);
expect(await new Response(cli.stderr).text()).toBe("");
```

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 "treats a closed output pipe as controlled" test wraps the fixture in bash -c '... | head -c 1', while the three other tests in the same file spawn the fixture directly via Bun.spawn with no shell. This is an internal style inconsistency and adds a POSIX-only dependency (bash, head) that will fail on Windows CI or minimal containers. The same scenario (read one byte, then close the consumer) can be expressed with Bun.spawn({ stdout: "pipe" }) + cli.stdout.getReader() + reader.read() + reader.cancel(), keeping the file shell-free.

  test("treats a closed output pipe as controlled", async () => {
    const child = Bun.spawn(
      [
        "bash",
        "-c",
        'set -o pipefail; "$1" "$2" --epipe | head -c 1 >/dev/null',
        "--",
        process.execPath,
        path.join(import.meta.dir, "fixture", "stdout.ts"),
      ],
      {
        cwd: path.join(import.meta.dir, "../.."),
        stdout: "ignore",
        stderr: "pipe",
      },
    )

Comment thread packages/cli/src/cli/stdout.ts Outdated
Comment thread packages/cli/src/cli/stdout.ts Outdated
@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 · ⚪ 2 · 0/7 resolved

  • packages/cli/src/cli/stdout.ts:4 — `error: undefined as unknown` cast is unidiomatic
  • 🟡 packages/cli/src/cli/stdout.ts:27-39 — write() never rejects while flush() throws
  • 🟡 packages/cli/src/cli/stdout.ts:41-44 — flush() throwing makes it unsafe for finally blocks
  • packages/cli/src/cli/stdout.ts:46-48 — Predicate `closed()` reads ambiguously; prefer isClosed()
  • 🟠 packages/cli/src/headless.ts:158-159 — flush() throw in finally skips process.exit()
  • 🟠 packages/cli/src/index.ts:216-217 — flush() throw in finally skips process.exit()
  • 🟡 packages/cli/test/cli/stdout.test.ts:20-35 — EPIPE test shells out to bash+head, unlike sibling tests
🤖 Fix all 7 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #95 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/stdout.ts:4 — `error: undefined as unknown` cast is unidiomatic
   Detail: The state object bootstraps `error` with `undefined as unknown` to coerce the inferred type from `undefined` to `unknown`. This reads as a code smell; declaring the state object's shape explicitly (or annotating `error: unknown` on the property) removes the cast and keeps initialization honest.
   Suggested fix: Annotate the property type directly:
```ts
const state: { bound: boolean; closed: boolean; error: unknown; pending: Set<Promise<void>> } = {
  bound: false,
  closed: false,
  error: undefined,
  pending: new Set<Promise<void>>(),
}
```
2. packages/cli/src/cli/stdout.ts:27-39 — write() never rejects while flush() throws
   Detail: Stdout.write returns a Promise<void> that ALWAYS resolves: the write callback stores any error into state.error via fail() and then resolves unconditionally. Errors only surface when flush() is later called and throws. This is asymmetric with flush()'s throw-on-error contract and breaks the usual Promise<void> intuition — callers who `await Stdout.write(...)` expecting it to reject on I/O failure will silently miss errors. The one caller in run.ts (emit()) does not await the returned promise, so the contract is currently load-bearing on remembering to call flush().
   Suggested fix: Either document the contract on write() (e.g. `// Resolves regardless; errors are surfaced by flush()`), or have write() reject when state.error is already set so individual awaits can observe prior failures.
3. packages/cli/src/cli/stdout.ts:41-44 — flush() throwing makes it unsafe for finally blocks
   Detail: flush() is intended to be awaited in the finally of both entrypoints before process.exit(). By throwing `state.error` it forces every caller in a shutdown path to remember a try/catch, and the current callers (index.ts, headless.ts) do NOT wrap it — so the throw propagates and skips process.exit() (see the two MAJOR findings). Either swallow inside flush and expose the error via a getter (e.g. `Stdout.error()`) for callers that care, or document on flush() that it must be wrapped on shutdown paths. As-is, the API is a foot-gun the only two callers in the repo have already stepped on.
   Suggested fix: Option A (consolidate at source): `export async function flush() { while (state.pending.size) await Promise.all(state.pending) }` and add `export function error() { return state.error }`. Option B (keep throw but document): add a JSDoc note that flush() throws on prior write errors and must be wrapped when used before process.exit().
4. packages/cli/src/cli/stdout.ts:46-48 — Predicate `closed()` reads ambiguously; prefer isClosed()
   Detail: `Stdout.closed()` is a boolean predicate but the bare-past-participle name reads as either an action ("close it") or a state ("is closed"). Most TS predicate conventions use `isX()`/`hasX()`; `isClosed()` is clearer at call sites like `Stdout.closed() ? 0 : 2` in the test fixture.
   Suggested fix: Rename `closed` -> `isClosed` (and update the fixture call site).
5. packages/cli/src/headless.ts:158-159 — flush() throw in finally skips process.exit()
   Detail: Same regression as index.ts: `await Stdout.flush()` is the first statement of the finally, and flush() throws when state.error is set (any non-EPIPE write error). The throw escapes the finally, so `process.exit()` on the next line is never executed. headless.ts is the JSON/NDJSON-heavy entrypoint — the primary case this PR targets — so a single errored NDJSON write during the run turns a successful exit into either a hang (subprocesses keeping the event loop alive) or a non-zero unhandled-rejection exit. The catch above only sets process.exitCode=1 for errors thrown by the try body; it does not catch a throw originating from finally.
   Suggested fix: Wrap the flush so the explicit exit always runs, and log the failure:
```ts
} finally {
  try { await Stdout.flush() } catch (e) { Log.Default.error("stdout flush", { error: e }) }
  process.exit()
}
```
6. packages/cli/src/index.ts:216-217 — flush() throw in finally skips process.exit()
   Detail: Stdout.flush() throws `state.error` after pending writes drain; that error is set by fail() for ANY non-EPIPE write error during the run. In the finally block, `await Stdout.flush()` propagates that throw, so the next statement `process.exit()` is never reached. The adjacent comment ("Explicitly exit to avoid any hanging subprocesses... docker run --init") states process.exit() is REQUIRED to kill subprocesses that hold the event loop open. Skipping it regresses that guarantee: on a non-EPIPE stdout error the process either hangs on a live subprocess, or exits non-zero via unhandled-rejection even though the main run completed successfully.
   Suggested fix: Wrap the flush so it cannot defeat the explicit exit, and surface the error via logging instead of propagation:
```ts
} finally {
  try { await Stdout.flush() } catch (e) { Log.Default.error("stdout flush", { error: e }) }
  process.exit()
}
```
7. packages/cli/test/cli/stdout.test.ts:20-35 — EPIPE test shells out to bash+head, unlike sibling tests
   Detail: The "treats a closed output pipe as controlled" test wraps the fixture in `bash -c '... | head -c 1'`, while the three other tests in the same file spawn the fixture directly via `Bun.spawn` with no shell. This is an internal style inconsistency and adds a POSIX-only dependency (bash, head) that will fail on Windows CI or minimal containers. The same scenario (read one byte, then close the consumer) can be expressed with `Bun.spawn({ stdout: "pipe" })` + `cli.stdout.getReader()` + `reader.read()` + `reader.cancel()`, keeping the file shell-free.
   Suggested fix: Replace the bash wrapper with a direct Bun spawn whose stdout is read for 1 byte then cancelled:
```ts
const cli = Bun.spawn([process.execPath, fixturePath, "--epipe"], { stdout: "pipe", stderr: "pipe" });
const reader = cli.stdout.getReader();
await reader.read();
await reader.cancel();
expect(await cli.exited).toBe(0);
expect(await new Response(cli.stderr).text()).toBe("");
```
📋 Out-of-diff findings (7)
Sev Location Finding
packages/cli/src/cli/stdout.ts:4 `error: undefined as unknown` cast is unidiomatic
🟡 packages/cli/src/cli/stdout.ts:27-39 write() never rejects while flush() throws
🟡 packages/cli/src/cli/stdout.ts:41-44 flush() throwing makes it unsafe for finally blocks
packages/cli/src/cli/stdout.ts:46-48 Predicate `closed()` reads ambiguously; prefer isClosed()
🟠 packages/cli/src/headless.ts:158-159 flush() throw in finally skips process.exit()
🟠 packages/cli/src/index.ts:216-217 flush() throw in finally skips process.exit()
🟡 packages/cli/test/cli/stdout.test.ts:20-35 EPIPE test shells out to bash+head, unlike sibling tests

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

Final bounded review round addressed at 6e4b02ff0 (no further automated review round requested).

Finding Verdict / action Resolution Verification
r3611179281 unidiomatic error cast TRUE · FIX Replaced the casted state property with an inferred failure: unknown binding. TypeScript typecheck — PASS
r3611179277 write/flush error asymmetry TRUE · FIX Non-EPIPE write callbacks now reject write(); already-recorded failures reject later writes; internally attached observers prevent ignored emitter writes from becoming unhandled rejections. flush() still reports the same failure after all callbacks settle. Injected-EIO subprocess verifies both write() and flush() reject — PASS
r3611179273 flush shutdown safety TRUE · FIX flush() preserves failure reporting, while both shutdown callers now catch, log, set exit status 1, and continue to mandatory process.exit(). Entry-point shutdown regression — PASS
r3611179283 ambiguous predicate TRUE · FIX Renamed closed() to isClosed(). OS-level EPIPE regression — PASS
r3611179267 headless forced exit skipped TRUE · FIX headless.ts catches drain failures and always reaches forced exit. Entry-point shutdown regression — PASS
r3611179269 CLI forced exit skipped TRUE · FIX index.ts catches drain failures and always reaches forced exit, preserving subprocess cleanup. Entry-point shutdown regression — PASS
r3611179279 bash/head portability TRUE · FIX Removed external bash and head dependencies. The test now uses Bun's cross-platform shell pipeline with a separate Bun consumer and a side-channel assertion proving the producer observed EPIPE. A direct ReadableStream.cancel() was not used because it was experimentally shown on Bun 1.3.10 to let the child exit without setting the EPIPE state. Portable real-pipe regression — PASS

Verification:

  • bun test test/cli/stdout.test.ts — 5 pass, 0 fail
  • bun test --timeout 30000 — 1329 pass, 7 skip, 0 fail (one unrelated URL-cache test flaked on the first full run, passed in isolation, then the complete rerun passed)
  • bun run typecheck — PASS
  • Prettier check on all changed TypeScript — PASS
  • repository pre-push bun turbo typecheck — 6/6 tasks successful

This is the second and final remediation round. No merge requested.

@byapparov byapparov self-assigned this Jul 19, 2026
@byapparov byapparov added the bug Something isn't working label Jul 19, 2026
Comment thread packages/cli/src/index.ts
@@ -212,5 +213,11 @@ try {
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
// Explicitly exit to avoid any hanging subprocesses.

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-catch block duplicated across both entrypoints.

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

Problem: Flush-catch block duplicated across both entrypoints
Detail: The exact 6-line `await Stdout.flush().catch(...)` block (flush, log via Log.Default.error, set process.exitCode=1) is copy-pasted verbatim in index.ts (lines 215-220) and headless.ts (lines 158-163). The consistency convention calls for logic used across files to live in a shared module. Extracting a helper (e.g. `Stdout.flushSafely()` on the same module, optionally taking a logger to keep stdout.ts log-agnostic) would remove the duplication and also relieve the structural test in stdout.test.ts that currently string-matches this exact block.
Suggested fix: Add a helper to packages/cli/src/cli/stdout.ts, e.g. `export async function flushSafely() { try { await Stdout.flush() } catch (error) { Log.Default.error("stdout flush failed", { error: error instanceof Error ? error.message : error }); process.exitCode = 1 } }`, then call `await Stdout.flushSafely()` from the `finally` block of both index.ts and 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

The exact 6-line await Stdout.flush().catch(...) block (flush, log via Log.Default.error, set process.exitCode=1) is copy-pasted verbatim in index.ts (lines 215-220) and headless.ts (lines 158-163). The consistency convention calls for logic used across files to live in a shared module. Extracting a helper (e.g. Stdout.flushSafely() on the same module, optionally taking a logger to keep stdout.ts log-agnostic) would remove the duplication and also relieve the structural test in stdout.test.ts that currently string-matches this exact block.

  // run using `docker run --init`.
  // Explicitly exit to avoid any hanging subprocesses.
  await Stdout.flush().catch((error) => {
      Log.Default.error("stdout flush failed", {
        error: error instanceof Error ? error.message : error,
      })
      process.exitCode = 1
    })
  process.exit()
}

Comment thread packages/cli/test/cli/stdout.test.ts Outdated
})

test("entrypoints handle flush failures before forced exit", async () => {
for (const entry of ["index.ts", "headless.ts"]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Entrypoint flush test is string-based, not behavioral.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #95, packages/cli/test/cli/stdout.test.ts:67-73):

Problem: Entrypoint flush test is string-based, not behavioral
Detail: The test "entrypoints handle flush failures before forced exit" reads the entrypoint source files as text and only checks for the substrings `await Stdout.flush().catch` and a subsequent `process.exit()`. It never spawns the entrypoints and never observes runtime behavior — e.g. that a flush rejection sets `process.exitCode=1` and the process exits non-zero. Every other test in this file spawns a subprocess and asserts observable behavior, so this structural test breaks the file's convention. It also gives false confidence: it would still pass if the `await` were removed (strings left in a comment), and it would break on a harmless refactor such as extracting the flush-catch into a helper or changing `process.exit()` to `process.exit(process.exitCode)`.
Suggested fix: Replace with a behavioral assertion: spawn index.ts / headless.ts in a configuration where Stdout.flush() rejects (e.g. pipe stdout to a reader that closes early and injects a non-EPIPE error, or wrap with a fixture that forces EIO) and assert `child.exited === 1` and that stderr contains the `stdout flush failed` log line.

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 test "entrypoints handle flush failures before forced exit" reads the entrypoint source files as text and only checks for the substrings await Stdout.flush().catch and a subsequent process.exit(). It never spawns the entrypoints and never observes runtime behavior — e.g. that a flush rejection sets process.exitCode=1 and the process exits non-zero. Every other test in this file spawns a subprocess and asserts observable behavior, so this structural test breaks the file's convention. It also gives false confidence: it would still pass if the await were removed (strings left in a comment), and it would break on a harmless refactor such as extracting the flush-catch into a helper or changing process.exit() to process.exit(process.exitCode).

  test("entrypoints handle flush failures before forced exit", async () => {
    for (const entry of ["index.ts", "headless.ts"]) {
      const source = await Bun.file(path.join(import.meta.dir, "../../src", entry)).text()
      const flush = source.lastIndexOf("await Stdout.flush().catch")
      expect(flush).toBeGreaterThan(-1)
      expect(source.indexOf("process.exit()", flush)).toBeGreaterThan(flush)
    }
  })

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 2 · ⚪ 0 · 0/2 resolved

  • 🟡 packages/cli/src/index.ts:215-220 — Flush-catch block duplicated across both entrypoints
  • 🟡 packages/cli/test/cli/stdout.test.ts:67-73 — Entrypoint flush test is string-based, not behavioral
🤖 Fix all 2 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #95 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/index.ts:215-220 — Flush-catch block duplicated across both entrypoints
   Detail: The exact 6-line `await Stdout.flush().catch(...)` block (flush, log via Log.Default.error, set process.exitCode=1) is copy-pasted verbatim in index.ts (lines 215-220) and headless.ts (lines 158-163). The consistency convention calls for logic used across files to live in a shared module. Extracting a helper (e.g. `Stdout.flushSafely()` on the same module, optionally taking a logger to keep stdout.ts log-agnostic) would remove the duplication and also relieve the structural test in stdout.test.ts that currently string-matches this exact block.
   Suggested fix: Add a helper to packages/cli/src/cli/stdout.ts, e.g. `export async function flushSafely() { try { await Stdout.flush() } catch (error) { Log.Default.error("stdout flush failed", { error: error instanceof Error ? error.message : error }); process.exitCode = 1 } }`, then call `await Stdout.flushSafely()` from the `finally` block of both index.ts and headless.ts.
2. packages/cli/test/cli/stdout.test.ts:67-73 — Entrypoint flush test is string-based, not behavioral
   Detail: The test "entrypoints handle flush failures before forced exit" reads the entrypoint source files as text and only checks for the substrings `await Stdout.flush().catch` and a subsequent `process.exit()`. It never spawns the entrypoints and never observes runtime behavior — e.g. that a flush rejection sets `process.exitCode=1` and the process exits non-zero. Every other test in this file spawns a subprocess and asserts observable behavior, so this structural test breaks the file's convention. It also gives false confidence: it would still pass if the `await` were removed (strings left in a comment), and it would break on a harmless refactor such as extracting the flush-catch into a helper or changing `process.exit()` to `process.exit(process.exitCode)`.
   Suggested fix: Replace with a behavioral assertion: spawn index.ts / headless.ts in a configuration where Stdout.flush() rejects (e.g. pipe stdout to a reader that closes early and injects a non-EPIPE error, or wrap with a fixture that forces EIO) and assert `child.exited === 1` and that stderr contains the `stdout flush failed` log line.
📋 Out-of-diff findings (2)
Sev Location Finding
🟡 packages/cli/src/index.ts:215-220 Flush-catch block duplicated across both entrypoints
🟡 packages/cli/test/cli/stdout.test.ts:67-73 Entrypoint flush test is string-based, not behavioral

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


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

@byapparov

Copy link
Copy Markdown
Contributor Author

Addressed the user-selected findings from the current-head review at 184091ddb.

Finding Verdict / action Resolution Verification
Duplicated guarded flush block TRUE · FIX · high confidence Extracted Shutdown.flush() into packages/cli/src/cli/shutdown.ts. It owns logging and exit-status policy while stdout.ts remains transport-only. Both entrypoints now call the same helper before their forced exit. CLI typecheck and workspace typecheck — PASS
String-based entrypoint test TRUE · FIX · high confidence Replaced source-text assertions with a subprocess regression using the production shutdown helper. The fixture injects EIO, awaits Shutdown.flush(), writes a marker only after control returns, and then forces exit. The test observes marker 1 and process exit code 1, proving the failure sets status without blocking forced exit. bun test test/cli/stdout.test.ts — 5 pass, 0 fail

Verification:

  • bun test test/cli/stdout.test.ts — 5 pass, 0 fail
  • bun test --timeout 30000 — 1329 pass, 7 skip, 0 fail
  • bun run typecheck — PASS
  • bun turbo typecheck — 6/6 tasks successful
  • Prettier check on all changed TypeScript — PASS

This user-triggered round is bounded; no further automated review loop is requested. No merge requested.

@byapparov
byapparov merged commit 131a269 into main Jul 20, 2026
5 checks passed
@byapparov
byapparov deleted the fix/issue-91-ndjson-flush branch July 20, 2026 07:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix NDJSON backpressure and shutdown flushing

1 participant