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
+17 -4
View File
@@ -1281,6 +1281,9 @@ candidate-metadata budgets, bounded path probes before canonical reads, and
complete UTF8 JSONL records. A full page advances its cursor without claiming a
blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane
geometry is excluded so changing devices does not invalidate transcript paging.
Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8,
under the global 1,024), so one polling agent retires its own oldest cursors instead
of evicting every other caller's from the shared FIFO.
⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store
walk per call. The first call walks (negative-filter prefixes read 1 KiB first,
@@ -1317,9 +1320,18 @@ and the first capture is often from before them; another writer or a lifecycle c
ends the wait at once, and every refusal after typing says the message may be in the
composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call
(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to
the two calls on any doubt; keep the poll anyway. While a guarded submit holds the
lease, `POST .../input` answers `409` (seq returned) instead of a `200` for unwritten
bytes. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its
the two calls only for output it cannot split; a combined call that THREW goes to the
capture's catch and is never rerun (a hung tmux would block the event loop for a second
timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives
while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`,
`_inGuardedTurn()`), and written in arrival order when the lease is released: the
browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by
N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written;
a held async write (mux write, named key) holds everything behind it until it settles
(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with
`1013` and applies nothing later from it, so the browser resends in order. `POST
.../input` alone answers `409` (seq returned) for the lease plus the drain, since its
browser drainer is serialized and retries. ⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its
checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and
an Enter within 120 ms of one becomes a newline while submit would report it
delivered. Stage cancels older verifier retries and sends no Enter. Receipts contain hashes and
@@ -1330,7 +1342,8 @@ Submission additionally binds input and geometry revisions and rechecks the
exact composer/caret at the conditional Enter boundary. The route must retain
the Session snapshot's staging generation rather than replacing it with the
inspection generation. Managed input and auxiliary named keys participate in the
same pending/lease boundary. Complete focus/mouse reports and empty writes are
same pending/lease boundary: they wait for the lease, and only a full hold queue
refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). Complete focus/mouse reports and empty writes are
not edits. Never retry ambiguous Enter delivery or clear an unverified composer.
The ordinary SubmitVerifier uses the shared write boundary, so each re-press
updates `lastSubmitAt` just like any other Enter.