Skip to content

Serializer convergence: route mailbox write edge through submitToSession (serialize gated deliveries vs interrupt/escape) #1365

Description

@mohidmakhdoomi

Summary

The gated mailbox delivery path and the escape/interrupt submission path take two disjoint locks, so a gated mailbox delivery can interleave with a concurrent --interrupt / --escape write to the same terminal. This is a known, deliberately-accepted boundary today (a gated delivery only ever writes onto a render-verified empty prompt, and interrupt/escape are explicit human gate-bypasses), but it is worth converging so the write edge is serialized end-to-end.

Split off from PR #1330 (Spec 1313 maintainer round) as agreed — not in scope for that PR.

Detail

  • Mailbox deliveries run through a per-agent write serializer (write-queue.ts, keyed by agentKey; completion-chains a message's text + Enter). Two mailbox deliveries to one agent cannot interleave.
  • escape / interrupt submissions take a per-terminal submission lock (submitToSession, session-submit.ts).
  • These are disjoint locks. A gated mailbox delivery is therefore not serialized against a concurrent escape/interrupt to the same terminal.

The boundary is already flagged in-code:

  • packages/codev/src/agent-farm/servers/session-submit.ts (the "Exactly what it covers — this is NOT blanket per-session atomicity" comment) documents that the mailbox delivery path is deliberately not covered by the submission lock, and why the residual cross-path race is accepted rather than a regression.
  • packages/codev/src/agent-farm/servers/tower-routes.ts (the immediate escape/interrupt write path, incl. the non-writable-session TERMINAL_NOT_WRITABLE 503 handling).

Proposed fix

Route the mailbox write edge through submitToSession so a gated delivery and an escape/interrupt submission take the same per-terminal lock. The per-terminal lock would be acquired as a leaf inside the per-agent serializer, so there is no lock-cycle hazard (the ordering is always per-agent → per-terminal, never the reverse).

Notes

Evaluate first (absorbed from #1480, 2026-08-17)

Before landing the routing change above, evaluate — end to end — how the three write paths compose, and let that evaluation ratify or supersede this issue's remedy. The interaction is currently a patchwork of separately-reasoned decisions rather than one coherent, documented model:

  • Immediate --interrupt/--escape takes the per-terminal submission lock (session-submit.ts) and keeps its documented "claim-first" tradeoff.
  • Gated mailbox delivery takes the disjoint per-agent write serializer (write-queue.ts, keyed by agentKey) — the accepted boundary documented in session-submit.ts's "Exactly what it covers" comment.
  • Delayed --interrupt (Spec 1313 maintainer round): the timer fires only the Ctrl+C at due time; the body delivers through the normal gated drainer after the turn ends.

Specific failure questions to answer:

  • Can a gated delivery interleave between a Ctrl+C and the post-interrupt clean prompt?
  • Does the delayed-interrupt reshape's "body delivers after the ^C" leave any window where the body lands mid-turn?
  • Is escalation/held state consistent if an interrupt tears the session mid-write?
  • Should the disjoint-lock boundary stay accepted, or converge (this issue's proposal)?

Outcomes: if the evaluation ratifies convergence, implement as proposed above. If it concludes the accepted boundary is correct as-is, close this issue as wontfix with the analysis attached. Either way, land the conclusion as an updated session-submit.ts boundary comment + arch.md §mailbox note, so the model lives in one place.

Interlock: #1481 (--interrupt-after) must build on whatever model this settles — sequence it after. Tracked by #1483 (workstream B).

Activity

  1. mohidmakhdoomi commented on Aug 17, 2026

    @mohidmakhdoomi
    CollaboratorAuthor

    On it! Working on this with the PIR protocol (plan + dev-approval gates before PR).

  2. added a commit that references this issue on Aug 17, 2026
  3. mohidmakhdoomi commented on Aug 17, 2026

    @mohidmakhdoomi
    CollaboratorAuthor

    Architect plan review — 3-way consultation on the PIR plan (pre-gate)

    Verdicts: gemini APPROVE · codex REQUEST_CHANGES · claude REQUEST_CHANGES (all HIGH confidence). Both REQUEST_CHANGES reviews ratify Part 1 — the evaluation is verified correct end-to-end (the architect independently confirmed the Ordering-2 mechanics against message-write.ts / mailbox-delivery.ts:458,463) and CONVERGE is the right call. The changes requested are all in Part 2's design. Consolidated, deduplicated:

    Blocking (revise the plan before the gate)

    1. In-lock precheck must re-check the row's own status (codex + claude). A row can be dismissed/superseded while the delivery waits on the terminal lock; the proposed precheck re-validates only writability + ringToken, widening the dismiss→bytes-on-wire window from zero to the lock wait. Add the getById status re-check to the precheck, and decide what WriteResult expresses for "stale row, no hold".
    2. Head-of-line blocking is a real liveness regression as designed (codex + claude). MailboxDrainer.tick awaits agents sequentially (mailbox-delivery.ts:644-664), so ONE agent blocked on a terminal lock stalls every other agent's deliveries, escalation and pruning; and worst-case lock hold is (lines−1)×10+80 ms — seconds for a newline-heavy body (HTTP accepts 1 MiB), not "~50–130 ms". Claude's proposed remedy fits the architecture: asymmetric try-lock — the delivery path fail-fasts to aborted:'busy' on a contended terminal (it would have aborted at the precheck anyway; the backstop re-delivers), while interrupt/escape continue to block. Same guarantee, no stall. Adopt it or answer it.
    3. State the echo-lag residual instead of overclaiming (codex + claude). ringToken/bytesWritten track output; the in-lock precheck cannot see an interrupt's just-written, not-yet-echoed input. The plan's "the in-lock precheck is not a refinement — it is the fix" overclaims: serialization is the structural guarantee; the precheck narrows (not closes) the residual, which remains Render gate: fuller close of the gate→write input race (R7 staleness) and input-echo-lag residual #1473's territory. Write it that way, including in the session-submit.ts boundary comment and the arch.md note.
    4. --escape is a second instance of the same bug (claude): its +50 ms Enter can submit a truncated in-flight multi-line delivery. Fold escape into Q1/Q3 and the test matrix as a first-class case, not only a "preserved semantics" check.
    5. Test-fake id hazard (claude): tower-routes.test.ts's gateSession() (~line 221) is un-annotated and reaches the real mailbox-wiring — a missing id compiles and keys every lock on undefined, i.e. a silently global lock. Add that file to the change list, a runtime id guard in submitMessagePaced, and a "deliveries to different terminals do not serialize" test.

    Non-blocking correctness notes for the revision

    • The "injected clock" as written cannot assert Enter-before-resolve ordering (pacing uses raw setTimeout) — use fake timers or thread the clock through submitMessagePaced.
    • File-list fixes: pty-session.ts lives under src/terminal/; arch.md §7 item 5 is ~line 1798; send-delivery.test.ts also carries inline ports.writeMessage overrides at :422/:604/:618.
    • Consider a hot-tier arch-critical.md fact for the agent→terminal lock-order invariant once landed.

    Full review texts available from the architect on request. Revise Part 2, then re-request plan-approval — Part 1 can be treated as ratified-by-review pending the human gate.

  4. added 14 commits that reference this issue on Aug 17, 2026
  5. added 2 commits that reference this issue on Sep 1, 2026
  6. added a commit that references this issue on Sep 6, 2026
  7. added 2 commits that reference this issue on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area/towerArea: Tower server / agent farm CLI

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions