Skip to content

Attach reliability: failure-mode map for every disconnect path (drive/view/passthrough, local and --node) #1597

Description

@khaliqgant

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:1788pty_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 errorfinish(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-528reserveStatusLineRow 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.
  • E4SIGCONT, 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

  1. A full write queue must not kill the input stream. Give queue-full a distinct code end to end (relay-pty → pty_workerlisten_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.
  2. 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.
  3. 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.
  4. 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.
  5. Clamp degenerate sizes in reserveStatusLineRow (item 6) — a few lines, removes a misleading error string permanently.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions