Candidate on base 94ece491f: 5 files, manifest f37bc933. Three mutants
killed; gate passed with no flakes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
9.6 KiB
Row 115 (#1609) round 1: a closed decision sends the raising role a message
Brief: docs/plans/2026-10-14_decision-close-message.md. Mos found the gap. Jason resolved decision 03130d08, and the PM, which raised it, never got a turn, because a close wrote only decision_events and nothing reached the role.
BASE: 94ece491f0. I built the candidate on 54b7c7b3e and moved the worktree to 94ece491f before the mutants and the gate. The commits between them touch only packages/ledger, docs/SESSIONS.md and the queue files, none of the manifest paths.
Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 5 files. I applied the patch to a fresh git archive 94ece491f export and every digest matched (DIGESTS_OK).
What changed
The close message
packages/bus/src/broker.mjs, the decision.resolve/decision.seen/decision.withdraw case. After the decision_events insert and the human's human.input answer event, a close with op !== 'seen' by a human, or by a run other than raised_by_run, inserts one message:
| Column | Value |
|---|---|
from_role, from_run |
human, human-cli for a human close; the closing role and run for a role close |
to_role |
d.raised_by_role |
class |
DECISION |
decision |
the decision's id |
in_reply_to, corrects |
null |
body |
Decision <id> on <action> was resolved by <by>: <choice> (<option text>). or Decision <id> on <action> was withdrawn by <by>., then \nNote: <note> when the close had a note |
<by> is the same by the decision_events row gets: the human's name, or the closing run. The option text comes from the decision's own options, which invalid-choice has already checked. The message is routed like message.send's, to the raising role's current holder, or with no holder until the next claim routes it.
It runs inside request()'s transaction, after every refusal check, so a refused close (invalid-choice, decision-closed, not-resolver) writes no message.
The cases that send nothing:
decision.seen, by theopcheck.- The raising run's own withdraw, by the run check. An agent withdraw is only ever the raising run (
not-resolverotherwise), so a role's withdraw never sends one. A human withdraw does. - A supersede. Its withdrawal is a direct
decision_eventsinsert insidedecision.raise, not this case. - A within-role decision. It closes inside
decision.raise, sodecision.resolveon it refusesdecision-closed.
The request
message.receive mapped request with one query, the human.input whose subject is the message id. That moves into a private #request(business, m), unchanged for every message except one kind. When there's no event for the message itself and the message is a DECISION from human that cites a decision, it returns the human.input on that decision with kind answer and op resolved or withdrawn. A decision has at most one close, because both close paths refuse decision-closed and the broker never writes expired, so that event is unique. A human message.send with class DECISION keeps its own instruction event, because the first query finds it. A role close's message has no human.input, so its request is null.
The runner
No change, as the brief says. turnRequest already prints citing decision and request, and the runner replies to from_role, so the role's RESULT goes to the human's inbox.
README
packages/bus/README.md:
- The
decision.resolverow says it sends the raising role a DECISION message. Thedecision.withdrawrow says a human withdraw does and the raising run's own withdraw doesn't. Themessage.receiverow says a human close's message carries the close's answer asrequest, and a role close's has null. - A new section, "Decision close messages (row 115, #1609)", after the decision authorization text. It gives the columns, the routing, both body forms,
<by>, the note line, the four cases that send nothing, the request, and that the message shows in the decision'strail.
unslop-check is clean on the README.
Scope notes
- packages/bus/tests/single-use.test.mjs. "message.send consumes approval and prevents a later send or authorize" counted every message after its approval and expected 1. The approval's resolve is a cross-role close by the arbiter, so it now sends the raiser a DECISION message too, and the count was 2. The test now counts the DECISION message (1) and the other messages (1) separately. The brief lists packages/bus/tests/ as owned, and this was the only existing test that changed.
- The decision's
trailgains the new message, since#trailadds messages whosedecisionis the decision. The human.input answer keeps the decision as its subject. The human resolve test checks the trail. agents. A role whose decision the human closed now has a routed message. It readsworkingwhile its runner takes the turn, thenidleonce the runner marks the message read. That's the turn the brief asks for, so I didn't change the state rules.- Nothing reads the new message outside the bus's own verbs. The Console, the notifier and Discord are out of scope by the brief and unchanged.
Tests
packages/bus/tests/decision-close.test.mjs is new, with 7 tests. Its fixture raises one decision routed to cto (technical), one to pm (delivery) and one gated to the human, all from coder.
| Test | Checks |
|---|---|
| a human resolve sends the raising role one message from the human, and its request is the answer | one DECISION message. Every column, the body with the note line, decision_events.by is jason, one routed delivery to coder-run, and coder's message.receive returns it with request equal to the answer event id. The message is in the decision's trail |
| a cross-role decision resolved by its arbiter comes from that role and run, with no request | from cto/cto-run to coder, the body with resolved by cto-run: yes (Allow). and no note line, request null |
| a human withdraw sends one message with the note | from human to coder, withdrawn by jason., a two-line note kept as written, request the answer |
| the raising run's withdraw, a supersede, a within-role raise and seen send nothing | 3 closes and 2 seen rows in decision_events, no DECISION message, coder's receive is empty |
| refused closes send nothing: invalid-choice, decision-closed, not-resolver | a human bad choice, pm resolving cto's decision, cto withdrawing coder's: no message. Then one real close, and the second resolve and a human withdraw of it refuse decision-closed with still one message |
| a raising role nobody holds at the close gets the message on the next claim | coder releases, the human resolves: one routed delivery with no holder. coder-run-2 claims, the message is routed to it, and its message.receive returns it with the answer as request |
| a human DECISION send keeps its own instruction as the request, not a close's answer | after a human resolve, a human message.send with class DECISION citing the same decision. Coder receives both: the close's has the answer, the send's has its own request |
packages/harness/tests/runner.test.mjs gains "a decision the human resolves becomes a turn citing the decision and the answer". The runner holds coder, coder raises a gated deploy, and the human resolves it. The coder's RESULT reaches the human in reply to the close message, and its echoed turn ends with Message <id> from human, sent <at>, class DECISION, citing decision <d>, request <answer>: and the body. The close message is marked read.
Mutants
mutants.py runs each mutant against the full bus suite (155 tests) and restores broker.mjs after each. Log: mutants-run.log, per-mutant output under mutants/.
| Mutant | Change | Result |
|---|---|---|
| m1 | the send condition becomes if (false) |
killed, 7 fail: the six close tests that expect a message, plus single-use's DECISION count |
| m2 | to_role becomes d.route_to |
killed, 6 fail: every close test that reads to_role or the coder's inbox |
| m3 | the condition drops the run check, if (op !== 'seen') |
killed, 1 fails: the raising run's withdraw test |
All three were restored, and git diff --stat matched the candidate afterwards.
Gate
gate.sh runs every package suite and every scripts/test-*.sh against the candidate worktree. GATE_PASSED at 16:43:58Z, 16:38:19 to 16:43:58. Log: gate-run.log, per-step logs under gate/.
| Step | Pass |
|---|---|
| bus | 155 (148 at BASE, plus the 7 new) |
| business | 67 |
| cli | 184 |
| control-board | 124 |
| conversation | 182 |
| discord | 178 |
| harness | 81 (80 at BASE, plus the runner test) |
| ledger | 86 |
| mosaic | 69 |
| queue | 156 |
| runs | 43 |
| seat | 120 |
| tasks | 92 |
| webui | 108 |
| scripts | auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 28 (node 156), release 14, task 98 |
No failures and no flakes. The bwrap tester case passed (gate/pkg-seat.log:77).
Sage saw a live managed coder session (claude -p, cwd ~/.mosaic-dev/workspaces/mosaic-stack/coder) run tests in its workspace at about 16:30Z, during their row 113 gate. My busy check now also flags test commands run from any process whose cwd is under ~/.mosaic-dev/workspaces/. It was clear before the mutants and before the gate, and a scan after the gate found no process with a cwd there. I can't rule out a short run between checks, but nothing failed.
The release step created the wt_default Docker network at 16:43:10Z. It had 0 containers, and I removed it.
Going live
This is a bus landing, so decision 109 applies. Mos gets a notice before and after landing. It goes live only through a stack copy, a re-pin and a bus restart on Mos's go. I start, stop and restart nothing.