fix(agent): hold input during a guarded submit instead of refusing it

The input lease is held across the awaited tmux Enter, so WebSocket frames
keep arriving while it is held. A tagged frame (seq N) was refused and its
seq rolled back, but the browser's WebSocket path does not wait for ACKs:
seq N+1 landed after the lease and moved the watermark past N, and the
redelivered N was then dropped as a duplicate. The keystroke was lost.

Input that arrives during the lease is now held and written in arrival order
when the lease is released, whatever the submit's outcome. A WebSocket frame
is applied and ACKed only once written; managed writes (writeViaMux) and
named keys (send-key) wait for their turn the same way, and a held write that
is still being sent holds everything behind it. A keystroke held before the
guard runs still moves the input revision, so it still invalidates the Enter.
The hold is capped per lease (256 inputs, 1 MiB): past it the WebSocket is
closed with 1013 and nothing later from it is applied, so the browser resends
every unACKed frame in order, and send-key answers 409 CONFLICT instead of
logging a tmux failure as INTERNAL_ERROR. POST .../input keeps its 409 for the
lease plus the held input's drain; its browser drainer is serialized.

Docs: correct the api-reference paragraph that said these keystrokes are
redelivered, and describe the capture fallback and the per-scope cursor cap
from the previous two commits in api-reference, architecture-invariants and
the changeset.
This commit is contained in:
Aamer Akhter committed 2026-10-26 09:19:05 -04:00
1 parent ecaddb40ae
commit 2aad93914e
9 files changed
+727 -100

No files matched your search

+22 -12
View File
@@ -427,8 +427,9 @@ turn that never started. Verified live; this is the most common silent failure o
this endpoint.
Any input, waiting or not, answers `409 CONFLICT` with nothing written while an
experimental guarded submit holds the session's input lease (a few milliseconds;
see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)).
experimental guarded submit holds the session's input lease, or input held behind
it is still being written (a few milliseconds; see
[guarded input](#experimental-bounded-agent-inspection-and-guarded-input)).
Retry it.
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
@@ -659,9 +660,10 @@ thread, task completion or authorization.
Opaque server-side cursors bind caller, session, pane generation, conversation,
opened-file identity, byte offset and bounded content anchors. Terminal resizes
do not change this inspection scope. At most 1024 live
cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh
tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign
do not change this inspection scope. Each owner and session keeps its newest 8
cursors (a retry of the last call still works; older ones are retired), at most
1024 live cursors are retained in all, each for 15 minutes; restart, eviction or
expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign
cursors or loss of the attributed source return `409 CONFLICT`. No transcript
filesystem path is returned as cursor/identity metadata; evidence retains the
owner's native record content, which can itself contain paths.
@@ -708,13 +710,21 @@ may already be in the composer: inspect the pane and follow the stranded-draft
recovery (Ctrl+U) in the skill reference before staging again.
While a guarded submit holds the input lease (the few milliseconds of its
conditional Enter), every other Codeman write path refuses, and
`POST /api/v1/sessions/:id/input` answers `409 CONFLICT` ("A guarded submit is in
progress on this session; nothing was written") instead of a `200` for bytes
that were never written. A tagged `clientId`/`seq` frame keeps its seq, so the
browser's durable queue, which keeps a frame on a `409`, delivers it on the next
retry. WebSocket keystrokes in that window are left unacknowledged and redelivered
the same way.
conditional Enter), input that reaches the session is held, not refused, and is
written in arrival order as soon as the lease is released, whatever the submit's
outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged
only once it is written, so a frame typed during the lease lands before the one
typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval
answers and Codeman's own managed writes. A held write that is still being sent
holds everything behind it. `POST /api/v1/sessions/:id/input` instead answers
`409 CONFLICT` ("A guarded submit is in progress on this session; nothing was
written") for that span, the lease plus the held input's drain, rather than a
`200` for bytes that were never written; a tagged `clientId`/`seq` frame keeps
its seq, so the browser's durable queue, whose POST fallback sends one frame at a
time and keeps it on a `409`, delivers it on the next retry. A lease holds at
most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and
nothing later from it is applied, so the browser reconnects and resends every
unacknowledged frame in order, and `send-key` answers `409 CONFLICT`.
Direct auxiliary key operations participate in the same pending-write and input
lease checks. Complete allowlisted focus/mouse reports and empty writes preserve