Skip to content

fix(cli): stop the drive-attach input-stream flood; recover or exit non-zero - #1453

Merged
khaliqgant merged 8 commits into
mainfrom
fix/1419-drive-input-stream-flood
Aug 7, 2026
Merged

fix(cli): stop the drive-attach input-stream flood; recover or exit non-zero#1453
khaliqgant merged 8 commits into
mainfrom
fix/1419-drive-input-stream-flood

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 7, 2026

Copy link
Copy Markdown
Member

Partial fix for #1419. Reported directly by Khaliq, who was hitting it repeatedly.

The bug

Attaching in drive mode floods the terminal, on repeat, with:

[drive] input stream send failed: PTY input stream is closed

Once it starts it never recovers. The session stays alive, looks idle, and exits 0 when the human finally presses Ctrl+C — so a supervisor sees a clean detach.

Root cause

Two independent halves.

Why the stream closes, and why only this one. A drive session holds two long-lived WebSockets and the broker treats them differently. The events/output WS is pinged every 30s (crates/broker/src/listen_api.rs:2932, sent at :2984). The PTY input WS is pinged by nobody in either direction — the broker's handler (listen_api.rs:1783-1866) only ever answers a Ping (:2036), and PtyInputStream (packages/harness-driver/src/transport.ts) never sends one. An idle input socket is therefore completely silent on the wire while the output socket is actively kept warm, so any idle timeout between client and broker reaps the input socket alone. A broker-side write error — a PTY worker restart — closes it the same way (listen_api.rs:1837-1843). Either path latches _closed at transport.ts:143.

That asymmetry is why this presents as a hang rather than a disconnect: the screen keeps updating while input is permanently dead. The keepalive gap is filed separately as #1450 — that is the actual prevention; everything in this PR is recovery.

Why it repeats forever — and it is not a retry loop. Nothing re-sends; there is no retry and no backoff anywhere on this path. PtyInputStream.send() checks _closed and rejects immediately with that exact string (transport.ts:194-203 — the only site producing this wording; the close path says PTY input stream closed (1006)). attach-drive.ts caught the rejection, logged, rolled back predictive echo, and returned. It never nulled the handle, never read the closed getter transport.ts:167 already exposes, and never called finish(). So the line repeats once per inbound stdin chunk, forever.

Why it floods rather than trickles

KeybindParser forwards every byte except 0x03 and 0x1d. If the agent's TUI has mouse tracking enabled — most do — every pointer movement over the terminal is an SGR mouse report on stdin, and each one produces a line. Khaliq never had to type. Anyone trying to reproduce this by typing slowly will wonder why their session is quiet; move the mouse instead. Focus in/out events do the same.

Reproduction

Reproduced on demand at three levels, all using the real close path rather than a mocked failure:

  1. Transport — open a real PtyInputStream, close the socket server-side with 1006, then send: 200 sends → 200 identical PTY input stream is closed rejections, zero reopen attempts, nothing back on the wire.
  2. CLI — a real runDriveSessionLoop with a dead input stream and 200 synthetic mouse reports and zero keystrokes: 200 flood lines, one unique message, session still running.
  3. Live broker — a throwaway bash PTY worker on a real local broker (11.4.2), real input WebSocket, real close event:
1. input stream OPEN against live broker; closed = false
2. real keystroke acked by broker: {"name":"zz-drive-repro-2","bytes_written":23}
3. released worker, status 200
4. stream.closed after worker death = false
5. sends after death: 25 rejections
6. breakdown: { "agent_not_found: no worker named 'zz-drive-repro-2'": 1,
                "PTY input stream is closed": 24 }
  1. End-to-end through the built CLI, on a real pty — the actual command, node agent attach <name> --mode drive, against a throwaway worker killed underneath a live session, driven by 200 SGR mouse reports and zero keystrokes:
exit code            : 1
"input stream lost"  : 1
"send failed" (old)  : 0
terminal message     : 1

[drive] input stream lost (write EPIPE); reconnecting…
[drive] input stream could not be reopened after 5 attempts (write EPIPE).
        Detaching — zz-drive-pty1 is still running; reattach to resume.

One line instead of two hundred, and a non-zero exit, with the human never touching the keyboard — the exact condition the original flood needed. Run on a genuine 80x24 pty (stdlib pty.openpty(); macOS script(1) cannot be used here, as it requires its own stdin to be a tty and exits 1 before starting the child, which is indistinguishable from the CLI's own failure exit).

Worth noting across runs 3 and 4: the close reason differed (agent_not_found vs write EPIPE) and both funnel into the same single liveness event, so the fix is not keyed to a particular error string.

Two things the live run showed that the unit repros did not:

  • The broker does not proactively close the input WS when the worker dies. stream.closed was still false immediately after the worker was released. The socket only fails on the next write, which errors (pty_input_erroragent_not_found) and closes the stream; every send after that hits the latched guard. So an idle drive session over a dead worker looks perfectly healthy until the human types — exactly the reported experience.
  • The first failure carries a different message from the other 24. The fix routes any send rejection into recovery, so the initial agent_not_found triggers the single liveness event and the remaining 24 never reach the log.

The fix

Both attach modes now share packages/cli/src/cli/lib/attach-input-recovery.ts. A closed input stream is a session-liveness event, not a per-keystroke error:

  • Surfaced once. The handle is dropped immediately so isUsable() short-circuits every chunk that arrives mid-outage — that is what actually silences the flood, not log dedup.
  • Bounded reopen with exponential backoff (5 attempts, 250ms doubling to 4s), cancellable on detach.
  • Exits non-zero with a readable message naming the agent and the way back in, so a supervisor can act instead of inheriting a dead seat that looks alive.
  • Input during the outage is dropped, not buffered — replaying stale keystrokes into a recovered PTY would execute them out of context.

attach-passthrough.ts carried the identical defect (:565) and is fixed by the same shared helper rather than a second copy.

Reopen will not silently reattach to a different worker

A reopen resolves by agent name, and a name is not an identity. If the worker died and something else claimed the name, a socket that opens successfully would route the human's keystrokes into a different agent's PTY. A restart of the same agent is equally wrong for input safety.

The broker exposes no per-worker-instance token — no instance_id, run_id, epoch, or absolute spawn timestamp reaches the wire. That gap is filed on its own as #1454, since it outlives this bug: anything that reconnects, adopts, or re-registers by name has the same blind spot.

What is available is two pids, and the live broker showed they are not interchangeable:

  • workerPid (worker.rs:243 = handle.child.id()) is the PTY child — the process whose terminal we are driving. Populated as soon as the worker spawns.
  • pid (worker.rs:242 = handle.harness_pid) is the harness wrapper, and stays null until the worker completes the ready handshake (worker_events.rs:849).

A live bash PTY worker reported pid: null, workerPid: 30209. The first version of this gate keyed on pid, which would have made identity unverifiable — and so every reopen refused — for exactly the class of worker drive attaches to. It now prefers workerPid and folds in the harness pid when both are present. The unit fakes had modelled pid as always present; only the live run caught this.

The gate fails closed: the replacement is refused when identity was unavailable at attach, unreadable after reconnect, or changed. null is treated as "cannot verify", never as "verified", and a refusal is not retried — a replaced worker does not become the original one on a later attempt.

This is honest about its limits: the OS can reuse a pid. The durable fix — tracked in #1454 — is for the broker to surface the per-spawn identity it already holds in memory: WorkerHandle.spawned_at (worker.rs:134), PersistedAgent.started_at (broker.rs:41, absolute unix seconds, already written to state.json), or the MetricsCollector per-name spawns/restarts counters (metrics.rs:45-47, already a working generation number). Each is a one-line surfacing job; none is exposed by any route today.

Tests — what each assertion catches

Red-checked: every assertion below was run against the pre-fix code and fails there, except where noted.

packages/harness-driver/src/pty-input-stream.test.ts

  • latches closed and rejects every later send with the same message — pins the invariant the CLI-side recovery is built on: the stream never self-heals (50 sends → 50 identical rejections, no replacement socket). If PtyInputStream ever grows its own reconnect, this fails and the CLI recovery must be revisited rather than silently doubling up.

attach-drive.test.tsrunDriveSession — lost PTY input stream

  • reports the loss exactly once no matter how much input arrivesthe flood assertion. 200 mouse reports, zero keystrokes; fails if the loss is announced more than once. Pre-fix: 200 lines.
  • exits non-zero with a readable message when every reopen failsthe exit-code assertion. Fails if the session resolves 0, and fails if the message omits the attempt count, the agent name, or the reattach instruction. Pre-fix: hangs, then exits 0.
  • tries exactly the configured number of reopens, then stops — catches unbounded recovery, which would turn a flood of log lines into a flood of sockets.
  • recovers and routes later keystrokes to the replacement stream — catches a reopen that reports success while input still goes nowhere, and asserts the outage-time keystroke was dropped rather than replayed.
  • refuses a reopen that landed on a different worker processthe identity assertion. Fails if the session accepts a replacement whose pid changed, or exits 0; also asserts the rejected stream was closed with nothing written to it.
  • refuses a reopen when worker identity cannot be read — fails closed on "don't know", not only on "known different".
  • refuses a reopen when identity was never established at attach — catches a null baseline being treated as a wildcard.
  • rolls back predictive echo once for input that never reached the PTY — five chunks in one outage must produce one rollback; a per-chunk implementation fails here. Catches the screen keeping optimistic glyphs for input the agent never received.
  • a detach during recovery still exits 0 and cancels the reopen — this one passes pre-fix (there was no recovery to interrupt); it guards the new code against misreporting a user detach as a transport failure and against a backoff timer firing into a torn-down session.

attach-drive.test.tsfetchWorkerIdentity

  • uses workerPid when the harness pid is null — pins the shape a live broker actually returns for a plain PTY worker. Catches the regression that would silently disable reopen for every non-handshaking worker.
  • folds in the harness pid when the broker has both / returns null when neither is present / returns null for an unlisted agent — a change in either process must register, and "no pid at all" must read as "cannot verify".

attach-passthrough.test.ts — the same flood and exit-code assertions against runPassthroughSession.

Full run: packages/cli/src/cli/lib + packages/harness-driver/src → 518 passed, 9 skipped. Repo-wide vitest run has 5 pre-existing failures in telemetry/cloud-auth/MCP-startup; verified identical on a clean main worktree, unrelated to this change.

Scope

Deliberately the bounded subset of #1419 — its acceptance criteria 2 and 3. The event-WS resume half (criteria 1, 4, 5: reconnect on 1000/1005 without detach, replay reconciliation and snapshot re-sync, queued-message repair, connection rotation) is a materially larger lifecycle rewrite and is left open in #1419 — please do not read that issue as closed by this PR.

🤖 Generated with Claude Code

…e error per keystroke

A drive session whose PTY input stream died logged

  [drive] input stream send failed: PTY input stream is closed

once per inbound stdin chunk, forever, and stayed alive while doing it.
The session was unusable but looked idle, and exited 0 when the human
finally pressed Ctrl+C.

There was no retry loop. The repetition was 1:1 with stdin: the SDK's
PtyInputStream latches `_closed` and rejects every later send with that
exact string (transport.ts:194-203), and the CLI caught the rejection,
logged, and returned without ever nulling the handle, reading the
`closed` getter it already exposes, or ending the session. Because
KeybindParser forwards every byte except Ctrl+C / Ctrl+], a source TUI
with mouse tracking on turned pointer movement into a flood with no
keystroke at all.

The stream dies easily and asymmetrically: the broker pings the events
WebSocket every 30s (listen_api.rs:2932/:2984) but never pings the input
WebSocket, so an idle input socket is silent on the wire and any idle
timeout reaps it alone — the screen keeps updating while input is dead.
A broker-side write error (PTY worker restart) closes it the same way.
The keepalive gap itself is filed separately as #1450.

Both attach modes now share `attach-input-recovery.ts`: report the loss
once, reopen with bounded exponential backoff, and exit non-zero with a
readable message when that is exhausted.

A reopen resolves by agent *name*, and a name is not an identity, so a
successful reopen is not accepted until the worker process is verified
unchanged. The broker exposes no per-instance token — only the harness
pid on GET /api/spawned (worker.rs:242) — so the check is a heuristic
that fails closed: identity missing at attach, unreadable after
reconnect, or changed all refuse the replacement rather than route
keystrokes into a PTY the human did not attach to.

Partial fix for #1419 — acceptance criteria 2 and 3. The event-WS resume
half (criteria 1, 4, 5: replay reconciliation, snapshot re-sync, queued
message repair, connection rotation) is deliberately left open there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 98038ce1-2e65-46b3-b970-afcbb7fd8da8

📥 Commits

Reviewing files that changed from the base of the PR and between eae3f9c and 36aa5fa.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/cli/src/cli/lib/attach-drive.test.ts
  • packages/cli/src/cli/lib/attach-input-recovery.test.ts
  • packages/cli/src/cli/lib/attach-input-recovery.ts
💤 Files with no reviewable changes (1)
  • packages/cli/src/cli/lib/attach-drive.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/cli/src/cli/lib/attach-input-recovery.test.ts
  • CHANGELOG.md
  • packages/cli/src/cli/lib/attach-input-recovery.ts

📝 Walkthrough

Walkthrough

PTY input streams now recover after closure or send failure. Recovery uses bounded backoff, worker identity checks, predictive-echo rollback, outage input dropping, teardown cancellation, and non-zero exhaustion results. Tests cover recovery, validation, cancellation, and permanent closure.

Changes

PTY input-stream recovery

Layer / File(s) Summary
Recovery controller
packages/cli/src/cli/lib/attach-input-recovery.ts
Adds failure classification, bounded retries, cancellation, stream replacement, identity validation, and cleanup.
Attach-session integration
packages/cli/src/cli/lib/attach-drive.ts, packages/cli/src/cli/lib/attach-passthrough.ts
Drive and passthrough sessions capture worker identities, route failures to recovery, drop outage input, and cancel recovery during teardown.
Recovery validation
packages/cli/src/cli/lib/attach-input-recovery.test.ts, packages/cli/src/cli/lib/attach-drive.test.ts, packages/cli/src/cli/lib/attach-passthrough.test.ts, packages/harness-driver/src/pty-input-stream.test.ts
Tests cover retries, identity checks, backpressure, replacement ownership, cancellation, detach handling, echo rollback, and permanent stream closure.
Release documentation
CHANGELOG.md
Documents bounded PTY input-stream recovery and worker identity checks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • AgentWorkforce/relay issue 1419 — Covers the same automatic PTY input-stream recovery behavior.

Possibly related PRs

Suggested reviewers: willwashburn

Sequence Diagram(s)

sequenceDiagram
  participant AttachSession
  participant InputStreamRecovery
  participant PTYInputStream
  participant BrokerConnection
  AttachSession->>InputStreamRecovery: report input-stream failure
  InputStreamRecovery->>PTYInputStream: open replacement stream
  InputStreamRecovery->>BrokerConnection: fetch worker identity
  BrokerConnection-->>InputStreamRecovery: return worker identity
  InputStreamRecovery-->>AttachSession: install verified replacement
Loading

Poem

A rabbit saw the PTY stream close,
Then bounded retries arose.
Identity checks guard the way,
Backpressure waits without delay.
Echo rolls back; lost input rests.
Exhaustion reports failed requests.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the bug, root cause, fix, scope, and test results, although it does not use the template's exact Test Plan and Screenshots sections.
Title check ✅ Passed The title clearly and concisely summarizes the main fix: stopping the input-stream flood and recovering or exiting non-zero.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1419-drive-input-stream-flood

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (6)
packages/cli/src/cli/lib/attach-input-recovery.ts (2)

126-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix the stale function name in the comment.

The comment names isDead(). The exported predicate is isUsable() (line 120). Callers short-circuit with !inputRecovery.isUsable(stream).

📝 Proposed comment fix
-    // Drop the dead handle first: `isDead()` then short-circuits every chunk
-    // that arrives mid-recovery, which is what actually silences the flood.
+    // Drop the dead handle first: `isUsable()` then short-circuits every chunk
+    // that arrives mid-recovery, which is what actually silences the flood.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-input-recovery.ts` around lines 126 - 127,
Update the recovery comment near the dead-handle cleanup to reference the
exported isUsable() predicate and its !inputRecovery.isUsable(stream)
short-circuit instead of the stale isDead() name.

110-121: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Make cancel() release the pending backoff waiter.

cancel() clears the timer but never resolves the promise created at line 207. If a caller calls cancel() while isSettled() is still false, the recovery loop never resumes and inFlight stays non-null, so recover() is blocked for the rest of the session. Both current call sites run inside finish() after settled = true, so this is not reachable today. The public contract on line 87 ("Cancel a pending backoff timer") does not state that requirement.

♻️ Proposed fix to make cancellation self-contained
   let inFlight: Promise<void> | null = null;
   let timer: ReturnType<typeof setTimeout> | null = null;
+  let cancelled = false;
+  let releaseWait: (() => void) | null = null;
 
   const cancel = (): void => {
+    cancelled = true;
     if (timer) {
       clearTimeout(timer);
       timer = null;
     }
+    releaseWait?.();
+    releaseWait = null;
   };

Then check cancelled next to each isSettled() guard in the loop, and set releaseWait = resolve inside the backoff promise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-input-recovery.ts` around lines 110 - 121,
Update the recovery loop and backoff promise to make cancellation
self-contained: assign the pending wait resolver to releaseWait, have cancel()
invoke it while clearing the timer, and check cancelled alongside each
isSettled() guard so a cancelled wait resumes and exits cleanly without leaving
inFlight blocked.
packages/cli/src/cli/lib/attach-passthrough.ts (2)

60-67: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the shared attach helpers out of attach-drive.ts.

attach-passthrough.ts now imports fetchWorkerIdentity and CliPtyInputStream from attach-drive.js. That makes passthrough depend on the drive module for shared contracts. attach-input-recovery.ts already imports CliPtyInputStream from the same place. A shared module (for example attach-input-recovery.ts or a new attach-shared.ts) would keep the dependency direction clean.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-passthrough.ts` around lines 60 - 67, Move
the shared attach contracts fetchWorkerIdentity and CliPtyInputStream out of
attach-drive.ts into a neutral shared module, then update attach-passthrough.ts
and attach-input-recovery.ts to import them from that module. Keep
attach-drive.ts using the shared definitions without making passthrough depend
on the drive module.

580-595: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated identity gate.

This verifyIdentity body is identical to the drive implementation in packages/cli/src/cli/lib/attach-drive.ts lines 988-1002, including the three reason strings. A divergence in one copy would produce inconsistent operator messages across the two attach modes. Move the gate into a helper exported from attach-input-recovery.ts that takes a baseline getter and a getWorkerIdentity function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-passthrough.ts` around lines 580 - 595,
Extract the shared identity verification logic from verifyIdentity in the
passthrough and drive attach flows into an exported helper in
attach-input-recovery.ts. Have the helper accept a baseline-identity getter and
getWorkerIdentity function, preserve the existing null checks, comparison, and
reason strings, and update both implementations to delegate to it.
packages/cli/src/cli/lib/attach-drive.ts (1)

1228-1236: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Confirm the added attach-time round trip is acceptable on the setup path.

deps.getWorkerIdentity runs inside the try block that reports "could not open PTY input stream". fetchWorkerIdentity catches its own errors and returns null, so the default path is safe. An injected implementation that rejects would abort the attach with a misleading message. The call also adds one GET /api/spawned round trip before raw mode is set.

Consider wrapping the call so a rejection degrades to null instead of failing attach.

♻️ Proposed hardening
-        attachedWorkerIdentity = await deps.getWorkerIdentity(connection, name);
+        attachedWorkerIdentity = await deps
+          .getWorkerIdentity(connection, name)
+          .catch(() => null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-drive.ts` around lines 1228 - 1236, Harden
the attach-time identity lookup around deps.getWorkerIdentity so any rejection
is converted to null and does not abort the initial attach. Preserve the
existing settled cleanup path and allow setup to continue without an identity
baseline, while keeping the current PTY error handling for actual stream
failures.
packages/cli/src/cli/lib/attach-passthrough.test.ts (1)

1284-1287: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the exhaustion message exists before matching its content.

find returns undefined when no line matches. expect(undefined).toContain(...) then fails with a type error instead of naming the missing message. The drive suite guards with toBeDefined() first (packages/cli/src/cli/lib/attach-drive.test.ts line 2236).

💚 Proposed test fix
     const exhausted = errors
       .map((args) => String(args[0]))
       .find((line) => line.includes('could not be reopened'));
+    expect(exhausted).toBeDefined();
     expect(exhausted).toContain('Alice is still running');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-passthrough.test.ts` around lines 1284 -
1287, Update the exhaustion assertion in the attach-passthrough test to first
verify that the value returned by errors.find for “could not be reopened” is
defined, then assert it contains “Alice is still running,” matching the guarded
assertion pattern used by the drive test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/cli/lib/attach-drive.ts`:
- Around line 1035-1041: Update the send rejection handlers in the attach-drive
flow around inputRecovery.recover and the corresponding attach-passthrough
handler to detect and ignore the input_backpressure error before recovery. Only
call inputRecovery.recover for closed or missing input-stream failures,
preserving the existing settled guard and error-description handling.

In `@packages/cli/src/cli/lib/attach-input-recovery.ts`:
- Around line 164-176: Ensure attemptReopen owns and closes replacement on every
exit path. In packages/cli/src/cli/lib/attach-input-recovery.ts#L164-L176, move
closeQuietly above attemptReopen, declare replacement as CliPtyInputStream |
null, and close it in the catch block before returning 'retry'. At
packages/cli/src/cli/lib/attach-input-recovery.ts#L184-L196, call
closeQuietly(replacement, `${label} client exiting`) before returning 'settled'.

---

Nitpick comments:
In `@packages/cli/src/cli/lib/attach-drive.ts`:
- Around line 1228-1236: Harden the attach-time identity lookup around
deps.getWorkerIdentity so any rejection is converted to null and does not abort
the initial attach. Preserve the existing settled cleanup path and allow setup
to continue without an identity baseline, while keeping the current PTY error
handling for actual stream failures.

In `@packages/cli/src/cli/lib/attach-input-recovery.ts`:
- Around line 126-127: Update the recovery comment near the dead-handle cleanup
to reference the exported isUsable() predicate and its
!inputRecovery.isUsable(stream) short-circuit instead of the stale isDead()
name.
- Around line 110-121: Update the recovery loop and backoff promise to make
cancellation self-contained: assign the pending wait resolver to releaseWait,
have cancel() invoke it while clearing the timer, and check cancelled alongside
each isSettled() guard so a cancelled wait resumes and exits cleanly without
leaving inFlight blocked.

In `@packages/cli/src/cli/lib/attach-passthrough.test.ts`:
- Around line 1284-1287: Update the exhaustion assertion in the
attach-passthrough test to first verify that the value returned by errors.find
for “could not be reopened” is defined, then assert it contains “Alice is still
running,” matching the guarded assertion pattern used by the drive test.

In `@packages/cli/src/cli/lib/attach-passthrough.ts`:
- Around line 60-67: Move the shared attach contracts fetchWorkerIdentity and
CliPtyInputStream out of attach-drive.ts into a neutral shared module, then
update attach-passthrough.ts and attach-input-recovery.ts to import them from
that module. Keep attach-drive.ts using the shared definitions without making
passthrough depend on the drive module.
- Around line 580-595: Extract the shared identity verification logic from
verifyIdentity in the passthrough and drive attach flows into an exported helper
in attach-input-recovery.ts. Have the helper accept a baseline-identity getter
and getWorkerIdentity function, preserve the existing null checks, comparison,
and reason strings, and update both implementations to delegate to it.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ead678c7-97fe-4554-a97f-1f1e2da19cbe

📥 Commits

Reviewing files that changed from the base of the PR and between 6466b84 and 6a9d485.

📒 Files selected for processing (6)
  • packages/cli/src/cli/lib/attach-drive.test.ts
  • packages/cli/src/cli/lib/attach-drive.ts
  • packages/cli/src/cli/lib/attach-input-recovery.ts
  • packages/cli/src/cli/lib/attach-passthrough.test.ts
  • packages/cli/src/cli/lib/attach-passthrough.ts
  • packages/harness-driver/src/pty-input-stream.test.ts

Comment thread packages/cli/src/cli/lib/attach-drive.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a9d485794

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +185 to +186
const verdict = await verifyIdentity();
if (isSettled()) return 'settled';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Close the replacement stream when recovery is cancelled

If the user detaches or the events WebSocket closes while verifyIdentity() is pending, teardown sees inputStream as null and cannot close this local replacement; once verification resolves, this branch returns without calling closeQuietly. The successfully opened input WebSocket therefore remains live, and because a normal code-0 attach does not force process.exit, it can keep the CLI running after Ctrl+C. Close the replacement before returning from this settled path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepted — fixed in d2a2b91. Verified the mechanism you describe: recover() calls setStream(null) up front, so teardown genuinely cannot see this replacement, and the settled-after-verify branch returned without closing it.

attemptReopen now owns replacement on every exit path — the settled-during-verify return and the abandoned-attempt catch both closeQuietly before returning.

Assertion: "closes the replacement stream when the user detaches mid-verification", which parks the verifier on a gate, detaches, then asserts the socket is closed.

Comment on lines +21 to +24
* This turns that into one liveness event: report once, reopen with bounded
* exponential backoff, and on exhaustion hand control back to the caller so the
* session exits non-zero. A dead seat that looks alive is the defect; a
* readable exit a supervisor can act on is the contract (#1419).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record the attach recovery in the changelog

This changes user-visible node agent attach --mode drive|passthrough behavior by adding recovery and non-zero failure exits, but the commit leaves the root [Unreleased] section empty. Add an impact-first Fixed entry and raise the heading to [Unreleased - Patch] as required for the first pending user-visible change.

AGENTS.md reference: AGENTS.md:L31-L38

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepted — fixed in d2a2b91. Heading raised to [Unreleased - Patch] with two impact-first Fixed entries: the flood/recovery/non-zero-exit change, and the same-worker verification on reopen.

The first entry names the mouse-tracking amplifier explicitly, since a reader who tries to reproduce this by typing will find a quiet session and conclude it is fixed already.

Comment on lines +1036 to +1040
void stream.send(decoded).catch((err: unknown) => {
if (settled) return;
const message = describeError(err);
deps.log(`[drive] input stream send failed: ${message}`);
// The keystroke never reached the PTY — drop any optimistic echo
// for it so the screen doesn't show input the agent didn't get.
predictiveEcho?.rollback();
// Only the first failure speaks; `recover` no-ops while a recovery
// is already in flight, and it does the echo rollback.
inputRecovery.recover(describeError(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.

P2 Badge Preserve healthy streams when sends hit backpressure

When the broker is slow enough that queued input exceeds PtyInputStream's 1 MiB high-water mark, send() rejects with input_backpressure while the stream remains open and usable. This unconditional catch treats that recoverable rejection as stream loss, closes the healthy socket, drops all outstanding input, and can detach non-zero if reopening or identity verification fails; passthrough has the same handler. Restrict recovery to errors that actually indicate a dead transport and retain the previous rollback/report behavior for backpressure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepted — fixed in d2a2b91. This is a regression this PR introduced, not a pre-existing issue, so thank you for catching it.

Confirmed against transport.ts:206-214: send() rejects input_backpressure with retryable: true before queuing, leaving the socket open and usable. The unconditional catch would have closed a healthy stream, dropped outstanding input, and re-run the identity gate — turning a briefly slow broker into a detach.

Send rejections now route through handleSendFailure, which classifies on the error code. Backpressure rolls back the optimistic echo (the keystroke genuinely did not land) and reports once per episode, reset on the next successful send — reporting per keystroke there would rebuild the exact flood this PR removes. Everything else is treated as loss.

Assertions: attach-drive.test.ts → "does NOT tear down a healthy stream when a send hits backpressure", plus three in attach-input-recovery.test.ts. Both red-checked.

Proactive Runtime Bot and others added 3 commits August 7, 2026 13:48
Verified against a live broker: a PTY worker reports `pid: null` and
`workerPid: 30209`. The harness `pid` (worker.rs:242 =
`handle.harness_pid`) stays null until the worker completes the harness
ready handshake (worker_events.rs:849), while `workerPid` (worker.rs:243
= `handle.child.id()`) is the PTY child itself and is populated as soon
as the worker spawns.

Keying the reopen identity gate on `pid` alone therefore made identity
unverifiable — and so every reopen refused — for exactly the class of
worker drive attaches to. Prefer `workerPid`, fold in the harness pid
when the broker has both, and return null only when neither is present.

`workerPid` is on the wire but absent from the `ListAgent` type, so it is
read off the record defensively rather than widening the contract here.

Found by the live-broker reproduction, not by the unit suite — the fakes
had modelled `pid` as always present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-passthrough.ts
Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts
Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-drive.test.ts
Proactive Runtime Bot and others added 2 commits August 7, 2026 14:31
Addresses the review findings on #1453. Two were defects this PR itself
introduced.

Identity gate is no longer optional (cubic P1). `verifyIdentity` was
optional, so a caller that omitted it reconnected the session to whichever
worker currently owned the agent name and forwarded the user's keystrokes
there — the exact reattach-by-name hole the gate exists to close. It is now
required by type and refused at runtime when absent.

Backpressure no longer tears down a healthy stream (codex P2, coderabbit
Major). `PtyInputStream.send()` rejects `input_backpressure` while the
socket is open and usable (transport.ts:206-214, retryable: true). The
unconditional catch treated that as transport loss, closing a healthy
socket, dropping outstanding input, and potentially detaching non-zero
because the broker was briefly slow. Send rejections now route through
`handleSendFailure`, which rolls back the echo and reports once per
episode for backpressure and only recovers on real loss.

Also fixed:

- A verifier that throws or stalls left the session with no input stream
  and no exhaustion path, so it hung instead of exiting non-zero (cubic
  P1). Both collapse into the existing `{ ok: false, reason }` refusal.
- `attemptReopen` now owns the replacement stream on every exit path
  (codex P1, coderabbit). The abandoned-attempt and settled-during-verify
  paths returned without closing it, leaking a live socket with no owner
  that could keep the CLI alive past a clean detach.
- `cancel()` resolves the pending backoff instead of only clearing the
  timer, so the loop unwinds and `inFlight` clears; it previously left the
  helper permanently `isRecovering()` and detach cleanup incomplete
  (cubic P2).
- Open and identity waits are bounded by `attemptTimeoutMs`, so a stalled
  socket or hung broker call cannot park the session in recovery forever
  and make `maxAttempts` a fiction (cubic P2).
- Ctrl+C sharing a stdin chunk with other bytes now still detaches during
  an outage in both modes; the dead-stream branch returned before the
  keybind actions ran, so "ab\x03" was swallowed and the human could not
  escape a broken session (cubic P2).
- CHANGELOG `[Unreleased - Patch]` entry for the user-visible attach
  behaviour change (codex P1).
- Detach-cancellation test now settles past the full 300ms backoff span
  rather than the first ~60ms, so a timer surviving teardown is actually
  caught (cubic P3).

New assertions, each red-checked against the vulnerable shape: a reopen
without a verifier is refused; backpressure does not tear down a healthy
stream or report more than once per episode; a throwing verifier exits
non-zero; a stalling verifier times out; abandoned and settled attempts
close their sockets; cancel ends the loop; Ctrl+C detaches mid-outage.

Live end-to-end re-verified after the change: real pty, real broker,
worker killed underneath, 200 mouse reports and zero keystrokes — one
line, exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/cli/src/cli/lib/attach-input-recovery.test.ts (1)

131-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stop the recovery loop before the test ends.

This test starts recovery and asserts synchronously. It never awaits the loop, so the backoff timer and the reopen attempt keep running after the test returns. The harness callbacks only append to local arrays, so the risk is low. Cancelling keeps the suite free of background work that outlives its test.

♻️ Proposed cleanup
     expect(h.recovery.isRecovering()).toBe(true);
     expect(h.logs.some((l) => l.includes('input stream lost'))).toBe(true);
+    h.settle();
+    h.recovery.cancel();
   });

Change the test callback to async for the cleanup above if you prefer to await settle() instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli/lib/attach-input-recovery.test.ts` around lines 131 -
138, Update the test using harness() and handleSendFailure() to stop the
recovery loop before it finishes, either by cancelling recovery explicitly or
making the test callback async and awaiting the harness cleanup/settle method.
Preserve the existing synchronous assertions for recovery state and the “input
stream lost” log.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 12: Update the changelog entry for the attach command to qualify that
every byte except Ctrl+C/Ctrl+] is forwarded during normal operation, while
input may be dropped when inputRecovery.isUsable(stream) is false during
recovery.
- Line 8: Update the changelog’s top-level pending-release heading from
“Unreleased - Patch” to the standard “Unreleased” form, while preserving the
patch-level designation for the eventual SemVer release heading.

---

Nitpick comments:
In `@packages/cli/src/cli/lib/attach-input-recovery.test.ts`:
- Around line 131-138: Update the test using harness() and handleSendFailure()
to stop the recovery loop before it finishes, either by cancelling recovery
explicitly or making the test callback async and awaiting the harness
cleanup/settle method. Preserve the existing synchronous assertions for recovery
state and the “input stream lost” log.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 42b76123-fcbb-4ee1-bf2a-b09516e6b7cc

📥 Commits

Reviewing files that changed from the base of the PR and between 367f3ad and eae3f9c.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • packages/cli/src/cli/lib/attach-drive.test.ts
  • packages/cli/src/cli/lib/attach-drive.ts
  • packages/cli/src/cli/lib/attach-input-recovery.test.ts
  • packages/cli/src/cli/lib/attach-input-recovery.ts
  • packages/cli/src/cli/lib/attach-passthrough.test.ts
  • packages/cli/src/cli/lib/attach-passthrough.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/cli/src/cli/lib/attach-passthrough.test.ts
  • packages/cli/src/cli/lib/attach-passthrough.ts
  • packages/cli/src/cli/lib/attach-drive.ts
  • packages/cli/src/cli/lib/attach-drive.test.ts

Comment thread CHANGELOG.md
Comment thread CHANGELOG.md Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/cli/src/cli/lib/attach-input-recovery.ts Outdated
Comment thread packages/cli/src/cli/lib/attach-drive.test.ts
…e refusal path

Second round of review findings on #1453. Three accepted, one rejected.

A verifier that throws synchronously still stranded the session. The type
permits a non-`async` function, and `verifyIdentity()` was evaluated in
argument position, so the throw escaped before `.catch()` was attached —
skipping the refusal, the non-zero exhaustion exit, and the close of the
replacement stream. Same failure the async-throw fix closed, reachable by
a different route. It is now invoked inside the promise chain.

Also:

- CHANGELOG: qualify the byte-forwarding claim. It described why the flood
  happened but read as a present-tense statement, which the recovery path
  now contradicts by dropping input while the stream is down.
- Drop the dead `identityCalls` array from the drive test harness. The
  earlier fix replaced it as the index source with `identityCallCount`,
  leaving it written but never read.

Rejected, with reasoning on the thread: CodeRabbit asked to lower the
changelog heading from `[Unreleased - Patch]` to `[Unreleased]`. AGENTS.md
requires the opposite — the first pending user-visible change must set a
release level, and the level is monotonic and must never be lowered. That
heading is there because an earlier codex P1 required it, citing the same
section.

New assertion, red-checked against argument-position invocation: "refuses
when the verifier throws synchronously" asserts the exhaustion exit runs
and the replacement socket is closed.

Prettier run over the changed files this time, so CI's formatting check
passes without a bot follow-up commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@khaliqgant
khaliqgant merged commit 7a42f3b into main Aug 7, 2026
41 checks passed
@khaliqgant
khaliqgant deleted the fix/1419-drive-input-stream-flood branch August 7, 2026 20:56
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.

1 participant