Room messages reach the live run (hook-injected steer) - #186
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
delivered_message_id— the newest room message this run has been shown. It lives outsideRunRow,snapshotMap,writeRow, andcomparableentirely, so it never joins a revision or a sync write (therun_credentialslocal-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.sail-room-relay. A new PostToolUse hook beside the heartbeat in the one hooks layer of both CLIs. Gates onSAIL_RUN_IDalone (the stop-gate env contract), so the fix lane gets delivery withoutSAIL_SPEC_IDand reviewer/engineer sessions are inert by construction. It readsGET /v1/run/messages(the credential names the run, the run names the spec), acknowledges before speaking, and emitshookSpecificOutput.additionalContextframed 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.stop-room-nudged— separate from the git-protocolstop-nudgedmarker, 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.AgentTaskPromptteaches 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.FixTaskBuildernow 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).PostToolUsehonorsadditionalContextexactly as Claude Code does (PreToolUseparses 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.ContainerSailSetup.installedFiles(); the machinery fingerprint changes and every container converges on next apply/dispatch. Asserted infingerprintCoversEveryInstalledFile.Server side
/v1/run/messages(local socket, run-credential only): GET returns undelivered non-own messages pluslatest(the newest id considered, own posts included, so the ack passes them); POSTdelivered=<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 anafter=cursor (exclusive withbefore).Tests
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.RoomRelayDeliveryITdrives 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 (noSAIL_SPEC_ID).mvn clean verifygreen, all JaCoCo gates met (api.* 100% on the touched included classes), no new dependencies, noThread.sleep.A small mast rider (separate PR in standardapplied/mast) makes the room composer state the delivery contract while an agent is working.