Summary
agent-relay node agent attach disconnects, flaps, or reports alarming errors during normal use. This issue maps every disconnect path in the attach stack: what triggers it, whether it self-heals, and what the operator sees. It is the prerequisite for the fixes proposed at the end.
Scope: packages/cli/src/cli/lib/attach*.ts (18 files), packages/harness-driver/src/transport.ts, crates/broker/src/listen_api.rs, crates/broker/src/pty_worker.rs, crates/relay-pty/src/pty.rs.
Headline answer to "is the input stream lost / reconnected pair benign?" The session does survive — but it is not benign, and it is not a cosmetic wording problem. The broker really closed the socket, the keystroke that triggered it was dropped, and the cause persists, so it recurs on the next keystroke for as long as the agent stays busy. The genuinely dangerous part is item 3 below: input already accepted by the broker is not dropped; it is replayed into the agent later, out of context.
Failure-mode map
1. Broker closes the PTY input WebSocket on any write error except worker_timeout
crates/broker/src/listen_api.rs:1788 — pty_input_error_is_connection_fatal(code) = code != "worker_timeout", applied at listen_api.rs:1896.
- Trigger: the worker returns
pty_write_failed (crates/broker/src/pty_worker.rs:993), most commonly because relay-pty's bounded write queue is full — WRITE_QUEUE_DEPTH = 128 at crates/relay-pty/src/pty.rs:31, message emitted at crates/relay-pty/src/pty.rs:502.
- Self-heals: yes — the client reopens the stream (
packages/cli/src/cli/lib/attach-input-recovery.ts:311, identity-gated). But the cause is unaffected by reconnecting, so the next keystroke reproduces it.
- Operator sees:
[drive] input stream lost (pty write queue full (128 writes pending; drainer wedged behind child not reading stdin)); reconnecting… followed by reconnected after 1 attempt(s). Indistinguishable from a fatal error.
- Cost: the triggering keystroke is dropped and its optimistic echo is rolled back.
- Verified by code path. The 128 cap itself was not reproduced live (see E2/E3).
2. The 128-slot queue is shared, so a human is punished for writes they did not make
crates/relay-pty/src/pty.rs:20-30: the same queue carries alacritty terminal-query replies (DSR/DA1/DA2/CPR), relay message injections, harness auto-responder writes, and human keystrokes.
- Human keystrokes are serialized per agent (
listen_api.rs:2027) and each waits up to PTY_INPUT_ACK_TIMEOUT = 5s (crates/broker/src/runtime/api.rs:28), so a human can add at most ~1 queue entry per 5 s. A human cannot fill 128 slots by typing.
- Therefore a queue-full error seen by an operator is essentially always caused by other writers — a busy TUI answering query sequences, or injected messages.
3. Accepted input is never dropped — it is replayed into the agent later ⚠️
- The CLI correctly drops its own keystrokes during an outage rather than buffering them (
packages/cli/src/cli/lib/attach-drive.ts stdin handler).
- But anything already accepted into the broker's drainer queue stays there and is written to the child whenever it resumes reading — with unbounded delay.
- Verified (E4): after
SIGCONT, agent-relay-broker dump-pty showed keystrokes typed minutes earlier and injected message bodies executing as shell commands in the agent.
- So the answer to "does reconnect drop or replay queued input" is: the client drops, the broker replays. Stale input does reach a live agent.
4. The events/screen WebSocket has no reconnect at all
packages/cli/src/cli/lib/attach-drive.ts:1445-1457: any error → finish(1); any close code other than 1000/1005 → finish(1). attach-view.ts / attach-passthrough.ts follow the same shape.
- No resume is attempted even though the stream URL already supports
?sinceSeq= (attach-drive.ts:740) and a StreamSyncBuffer already reconciles replayed chunks.
- Self-heals: no. One transient blip ends the session.
- Operator sees:
[drive] connection closed (code: 1006) and an exit.
- This is the most likely explanation for "I notice when attached it sometimes disconnects".
5. The PTY input WebSocket has no keepalive in either direction
- The broker pings the events WS every 30 s (
listen_api.rs:2999, listen_api.rs:3049). handle_pty_input_ws only answers pings (listen_api.rs:2098) and never sends one; the client never pings either (packages/harness-driver/src/transport.ts has no ping path).
- An idle input socket is therefore silent on the wire and dies to any intermediary idle timeout (cloud edge on
--node attaches, NAT, proxy) while the screen keeps updating — a seat that looks alive with dead input until the next keystroke.
- This is documented as a known hazard in
attach-input-recovery.ts:1-25; the response was client-side recovery, never a transport-level keepalive.
6. Degenerate terminal size is forwarded verbatim as a resize
packages/cli/src/cli/lib/attach.ts:519-528 — reserveStatusLineRow returns the size unchanged when rows < 3 || cols < 2, so a 0x0 TTY (e.g. script(1) with no winsize, a minimized window, a size not yet reported) is sent as rows: 0, cols: 0, and is rejected by attach-fleet-node.ts:675 / pty_worker.rs:936 as invalid_dimensions.
- Operator sees:
[drive] could not sync agent PTY size to local terminal (rows and cols must be positive integers); continuing.
- Non-fatal — verified, the session continues — but this string has already sent two separate investigations down the wrong path.
7. Cross-node agent without --node returns a near-empty stream instead of an error
- Attaching to an agent that lives on another node without
--node produced ~54 bytes of stream rather than an error naming the node. The fleet placement hint (packages/cli/src/cli/lib/fleet-hint.ts) only fires on a 404 from the delivery-mode call, so a name the local broker resolves but cannot render never reaches it.
- Not yet root-caused. Silent wrong answer.
8. Age-dependent behaviour (relay#1593) — unverified
Attach to agents older than ~5 h reportedly behaves differently from fresh ones. Not reproduced here; noted so it is not lost.
Experiments (evidence)
Run against a throwaway sh agent (wedge-probe-0821) on the local broker, driven from a real PTY (40×120) by a Python pty.fork() harness, stdout and stderr captured separately. The flagship lead was never driven.
- E1 — drive attach to a live agent: works; status line renders; keystrokes land.
- E2 — child
SIGSTOPped (a faithful stand-in for a harness that stops reading stdin mid-tool-call) + 2500 keystrokes: exactly one line, [drive] <agent> did not confirm a keystroke in time (worker busy); it may still land. Session survived, no reconnect. The relay#1544 handling works as designed.
- E3 — 150
--mode steer DMs injected into the wedged agent, then typing: same benign path; the 128 cap was still not reached.
- E4 —
SIGCONT, then dump-pty: keystrokes and injected message bodies from minutes earlier executed as shell commands. This is item 3.
- OS note — on macOS a pty master write blocks once the slave's input queue fills (measured: ~768 bytes with the slave in raw mode) but discards in canonical mode. So the wedge requires a child whose tty is in raw mode — i.e. exactly a TUI harness, which is why this is only seen against real agents.
Proposal, ranked by operator impact per line changed
- A full write queue must not kill the input stream. Give queue-full a distinct code end to end (relay-pty →
pty_worker → listen_api) and exempt only that code in pty_input_error_is_connection_fatal, exactly as worker_timeout is exempt. The worker is provably alive — it refused one write. This turns the entire flap in item 1 into a single non-fatal line. Highest leverage in the stack.
- Keepalive on the PTY input WS (client-side ping, ~20 s). Closes item 5 for every broker version, since the broker already answers pings. Removes the silent "screen alive, input dead" seat.
- Truthful operator messaging. A recovered flap and a fatal disconnect must not read alike: on recovery say the session is usable and what was lost; on a real disconnect keep the loud message. Closes the design constraint and item 1's readability half.
- Make backpressure visible before the cap, not after it wedges: report the drainer queue depth on the write ack and surface a status-line indicator when the agent stops consuming input.
- Clamp degenerate sizes in
reserveStatusLineRow (item 6) — a few lines, removes a misleading error string permanently.
- Events-WS resume via
sinceSeq (item 4). The biggest operator win for "stay attached", but also the biggest change; proposed as a follow-up rather than bundled here.
Deliberately not doing:
- Rewriting the attach modules into one. The surface is ~2900 lines across three files plus a module and test file per mode; the failure modes above are all local.
- Client-side keystroke replay after a reconnect. Item 3 shows delayed replay is already the hazard; adding more of it would make a live agent execute stale input.
- Silencing any disconnect to make attach look solid. Every change above either keeps the message or makes it more specific.
Summary
agent-relay node agent attachdisconnects, flaps, or reports alarming errors during normal use. This issue maps every disconnect path in the attach stack: what triggers it, whether it self-heals, and what the operator sees. It is the prerequisite for the fixes proposed at the end.Scope:
packages/cli/src/cli/lib/attach*.ts(18 files),packages/harness-driver/src/transport.ts,crates/broker/src/listen_api.rs,crates/broker/src/pty_worker.rs,crates/relay-pty/src/pty.rs.Headline answer to "is the
input stream lost / reconnectedpair benign?" The session does survive — but it is not benign, and it is not a cosmetic wording problem. The broker really closed the socket, the keystroke that triggered it was dropped, and the cause persists, so it recurs on the next keystroke for as long as the agent stays busy. The genuinely dangerous part is item 3 below: input already accepted by the broker is not dropped; it is replayed into the agent later, out of context.Failure-mode map
1. Broker closes the PTY input WebSocket on any write error except
worker_timeoutcrates/broker/src/listen_api.rs:1788—pty_input_error_is_connection_fatal(code) = code != "worker_timeout", applied atlisten_api.rs:1896.pty_write_failed(crates/broker/src/pty_worker.rs:993), most commonly because relay-pty's bounded write queue is full —WRITE_QUEUE_DEPTH = 128atcrates/relay-pty/src/pty.rs:31, message emitted atcrates/relay-pty/src/pty.rs:502.packages/cli/src/cli/lib/attach-input-recovery.ts:311, identity-gated). But the cause is unaffected by reconnecting, so the next keystroke reproduces it.[drive] input stream lost (pty write queue full (128 writes pending; drainer wedged behind child not reading stdin)); reconnecting…followed byreconnected after 1 attempt(s). Indistinguishable from a fatal error.2. The 128-slot queue is shared, so a human is punished for writes they did not make
crates/relay-pty/src/pty.rs:20-30: the same queue carries alacritty terminal-query replies (DSR/DA1/DA2/CPR), relay message injections, harness auto-responder writes, and human keystrokes.listen_api.rs:2027) and each waits up toPTY_INPUT_ACK_TIMEOUT = 5s(crates/broker/src/runtime/api.rs:28), so a human can add at most ~1 queue entry per 5 s. A human cannot fill 128 slots by typing.3. Accepted input is never dropped — it is replayed into the agent later⚠️
packages/cli/src/cli/lib/attach-drive.tsstdin handler).SIGCONT,agent-relay-broker dump-ptyshowed keystrokes typed minutes earlier and injected message bodies executing as shell commands in the agent.4. The events/screen WebSocket has no reconnect at all
packages/cli/src/cli/lib/attach-drive.ts:1445-1457: anyerror→finish(1); any close code other than 1000/1005 →finish(1).attach-view.ts/attach-passthrough.tsfollow the same shape.?sinceSeq=(attach-drive.ts:740) and aStreamSyncBufferalready reconciles replayed chunks.[drive] connection closed (code: 1006)and an exit.5. The PTY input WebSocket has no keepalive in either direction
listen_api.rs:2999,listen_api.rs:3049).handle_pty_input_wsonly answers pings (listen_api.rs:2098) and never sends one; the client never pings either (packages/harness-driver/src/transport.tshas no ping path).--nodeattaches, NAT, proxy) while the screen keeps updating — a seat that looks alive with dead input until the next keystroke.attach-input-recovery.ts:1-25; the response was client-side recovery, never a transport-level keepalive.6. Degenerate terminal size is forwarded verbatim as a resize
packages/cli/src/cli/lib/attach.ts:519-528—reserveStatusLineRowreturns the size unchanged whenrows < 3 || cols < 2, so a0x0TTY (e.g.script(1)with no winsize, a minimized window, a size not yet reported) is sent asrows: 0, cols: 0, and is rejected byattach-fleet-node.ts:675/pty_worker.rs:936asinvalid_dimensions.[drive] could not sync agent PTY size to local terminal (rows and cols must be positive integers); continuing.7. Cross-node agent without
--nodereturns a near-empty stream instead of an error--nodeproduced ~54 bytes of stream rather than an error naming the node. The fleet placement hint (packages/cli/src/cli/lib/fleet-hint.ts) only fires on a 404 from the delivery-mode call, so a name the local broker resolves but cannot render never reaches it.8. Age-dependent behaviour (relay#1593) — unverified
Attach to agents older than ~5 h reportedly behaves differently from fresh ones. Not reproduced here; noted so it is not lost.
Experiments (evidence)
Run against a throwaway
shagent (wedge-probe-0821) on the local broker, driven from a real PTY (40×120) by a Pythonpty.fork()harness, stdout and stderr captured separately. The flagship lead was never driven.SIGSTOPped (a faithful stand-in for a harness that stops reading stdin mid-tool-call) + 2500 keystrokes: exactly one line,[drive] <agent> did not confirm a keystroke in time (worker busy); it may still land.Session survived, no reconnect. The relay#1544 handling works as designed.--mode steerDMs injected into the wedged agent, then typing: same benign path; the 128 cap was still not reached.SIGCONT, thendump-pty: keystrokes and injected message bodies from minutes earlier executed as shell commands. This is item 3.Proposal, ranked by operator impact per line changed
pty_worker→listen_api) and exempt only that code inpty_input_error_is_connection_fatal, exactly asworker_timeoutis exempt. The worker is provably alive — it refused one write. This turns the entire flap in item 1 into a single non-fatal line. Highest leverage in the stack.reserveStatusLineRow(item 6) — a few lines, removes a misleading error string permanently.sinceSeq(item 4). The biggest operator win for "stay attached", but also the biggest change; proposed as a follow-up rather than bundled here.Deliberately not doing: