Skip to content

Room messages reach the live run (hook-injected steer) - #186

Merged
udaychandra merged 5 commits into
mainfrom
agent/sail-room-agent-loop
Aug 10, 2026
Merged

Room messages reach the live run (hook-injected steer)#186
udaychandra merged 5 commits into
mainfrom
agent/sail-room-agent-loop

Conversation

@udaychandra

Copy link
Copy Markdown
Contributor

Closes the loop on spec sail-room-agent-loop: a human replying in a spec room mid-run now reaches the running agent instead of an inbox nobody reads. One watermark, three delivery points, no double delivery.

Architecture

  • Watermark. The run row records delivered_message_id — the newest room message this run has been shown. It lives outside RunRow, snapshotMap, writeRow, and comparable entirely, so it never joins a revision or a sync write (the run_credentials local-only precedent, stronger than _-prefixing). Initialized at launch to the newest message the dispatch prompt (or fix task) rendered; message ids are UUIDv7 strings, so forward-only CAS is a plain string compare.
  • Delivery point 1 — sail-room-relay. A new PostToolUse hook beside the heartbeat in the one hooks layer of both CLIs. Gates on SAIL_RUN_ID alone (the stop-gate env contract), so the fix lane gets delivery without SAIL_SPEC_ID and reviewer/engineer sessions are inert by construction. It reads GET /v1/run/messages (the credential names the run, the run names the spec), acknowledges before speaking, and emits hookSpecificOutput.additionalContext framed per message as [Room message from <author>, arrived while you were working]: <body>. Fail-open on every miss; an interval stamp caps it at one API round-trip per 15s.
  • Delivery point 2 — stop-gate last look. Undelivered messages block the stop once with their bodies as the reason and are acknowledged in the same pass, under stop-room-nudged — separate from the git-protocol stop-nudged marker, so a message arriving after a git nudge still gets its one block and neither a message storm nor a dirty tree can wedge a run.
  • Delivery point 3 — checkpoint reads. AgentTaskPrompt teaches the real contract (replies are injected after tool calls; unread messages block the first stop) and the read verb: spec comments <id> when blocked and before the final summary. A run reading its own room's newest page through the API advances its watermark — a read is a delivery.
  • Fix-lane conversation. FixTaskBuilder now renders the newest-20 room conversation before the findings, so human guidance posted during review reaches the fix agent that acts on that review; the watermark seeds at fix launch (rendered = delivered).
  • Codex parity is real. Checked against current Codex hooks documentation: PostToolUse honors additionalContext exactly as Claude Code does (PreToolUse parses but rejects it — deliberately not used). The one relay script serves both CLIs; the prompt and the mast composer hint state the same contract for both, because it is the same.
  • Rollout is the fingerprint. The relay ships by riding ContainerSailSetup.installedFiles(); the machinery fingerprint changes and every container converges on next apply/dispatch. Asserted in fingerprintCoversEveryInstalledFile.

Server side

/v1/run/messages (local socket, run-credential only): GET returns undelivered non-own messages plus latest (the newest id considered, own posts included, so the ack passes them); POST delivered=<id> is a forward-only CAS validated against the run's own spec — no client-chosen ids, stale acks are idempotent no-ops. The spec messages resource gains an after= cursor (exclusive with before).

Tests

  • Script harness tests execute the real shell: relay delivery/ack ordering, interval gate, fail-open paths, inertness without SAIL_RUN_ID; stop-gate room blocks (alone, after a git nudge, combined with a dirty tree), marker independence, and hermeticity against the dev box's live socket.
  • RoomRelayDeliveryIT drives the production local API end to end: mid-run post → real relay over a real Unix socket → injected context + advanced watermark → real stop gate passes as already-delivered; dead socket → silent exit, in the fix lane's exact environment (no SAIL_SPEC_ID).
  • Store tests pin the sync contract: advancing the watermark journals no revision, never appears in a comparable snapshot, and survives sync adoption of the same run.
  • mvn clean verify green, all JaCoCo gates met (api.* 100% on the touched included classes), no new dependencies, no Thread.sleep.

A small mast rider (separate PR in standardapplied/mast) makes the room composer state the delivery contract while an agent is working.

A human reply posted mid-run used to land in an inbox nobody reads. Now the
room is a live channel with one watermark, three delivery points, and no
double delivery:

- Each run row records delivered_message_id — node-local operational state
  kept out of RunRow snapshots, revisions, and sync writes entirely, so
  delivery bookkeeping never churns revisions or conflicts. Initialized at
  launch to the newest message the dispatch or fix prompt rendered.
- The local API grows a run-credential-scoped delivery lane: GET
  /v1/run/messages returns undelivered non-own messages plus the newest id
  considered; POST acknowledges with a forward-only CAS advance validated
  against the run's own spec. The spec messages resource gains an after=
  cursor, and a run reading its own room's newest page auto-advances its
  watermark (a read is a delivery).
- sail-room-relay, a new PostToolUse hook beside the heartbeat in the one
  hooks layer of both CLIs, gates on SAIL_RUN_ID alone, asks the API for
  fresh messages at most once per 15s via an interval stamp, acknowledges
  before speaking, and emits hookSpecificOutput.additionalContext framed as
  '[Room message from <author>, arrived while you were working]'. Codex's
  PostToolUse honors additionalContext exactly as Claude Code does, so both
  CLIs get real mid-run delivery. Fail-open everywhere: a chatty room or a
  down socket never breaks a build.
- The stop gate takes a last look: undelivered messages block the stop once
  with their bodies as the reason and are acknowledged in the same pass,
  under stop-room-nudged — a marker separate from the git-protocol nudge, so
  a message arriving after a git nudge still gets its one block and neither
  concern can wedge a run.
- FixTaskBuilder renders the room conversation so human guidance on disputed
  findings reaches the fix turn; AgentTaskPrompt teaches the delivery
  contract and the spec comments read verb.
- The relay rides the content-addressed machinery fingerprint — that is the
  rollout.
…watermark

The scalar UUIDv7 watermark assumed mint order equals arrival order, but
messages sync between boxes: an older-id message landing after the watermark
advanced past it was silently lost forever. Delivery is now a per-run ledger
of exact message identities (run_delivered_messages; the unreleased
delivered_message_id migration entry is rewritten in place — no released
binary ever applied it):

- The inbox lists messages absent from the ledger, probes limit+1, and
  reports has_more; acknowledgements name the exact ids shown
  (delivered=<id>[,<id>...]), so a capped or paged read can never imply
  delivery of what it omitted.
- A run reading its own room acknowledges exactly the page it was shown.
- The stop gate acknowledges before spending its one room marker or emitting
  the block; a failed acknowledgement drops the room block and leaves the
  messages undelivered for the relay or a later stop. When the inbox is
  capped, the block reason points the agent at the rest of the room. The
  block JSON reason rides stdin so maximum-size room messages no longer trip
  the kernel's per-argument exec limit, and the nudge publish is truncated.
- Dispatch seeds the ledger inside the guarded scope, so a failed seed
  releases the reservation instead of stranding a reserved run.
…de with their run

The relay now prints the hook output before acknowledging the batch, so a
failure or kill before the ack leaves the ledger untouched and the batch
retries next check — at-least-once delivery where ack-first could record
messages the agent never saw. The hook timeout gains margin (10s -> 15s)
over the two 5s curl deadlines, matching the stop gate.

run_delivered_messages gains ON DELETE CASCADE foreign keys to runs and
spec_messages, so a deleted run leaves no orphaned delivery rows. The
migration is edited in place: it has never shipped beyond this branch.
Cascade is safe because both stores upsert via ON CONFLICT DO UPDATE and
every run delete is a true deletion, never a delete-reinsert.
…d identity

Closes the escalated carried finding (UUID ordering permanently loses
messages that synchronize late) at its last remaining site, plus the
truncation finding, with one invariant: a message is acknowledged as
delivered only when it was presented in full, identified by exact id
from the same in-memory snapshot the prompt was built from.

markDeliveredThrough is deleted — its INSERT...SELECT id <= boundary
re-queried the live table after the prompt snapshot, so an older-id
message syncing into that window was ledgered without ever being seen,
permanently. Both launch seeds (dispatch and fix lane) now pass the
exact rendered ids to markDelivered. PromptConversation reports which
entries it rendered in full; the prompt builders surface that list, so
a message the 32k budget truncated or omitted keeps its ledger absence
and reaches the agent complete through the relay or the stop gate.
An ack naming no ids is a no-op, never an error — the relay may fire
with nothing to acknowledge.
@udaychandra
udaychandra merged commit 2dfb635 into main Aug 10, 2026
3 checks passed
@udaychandra
udaychandra deleted the agent/sail-room-agent-loop branch August 10, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant