Files
stack/agents/darkwing/work/queue-115/r1/build.md
T
jason.woltjeandClaude Opus 5.5 b3fca1f1a6 docs: row 115 round 1 packet, decision close message (#1609)
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]>
2026-10-14 11:44:46 -05:00

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 the op check.
  • The raising run's own withdraw, by the run check. An agent withdraw is only ever the raising run (not-resolver otherwise), so a role's withdraw never sends one. A human withdraw does.
  • A supersede. Its withdrawal is a direct decision_events insert inside decision.raise, not this case.
  • A within-role decision. It closes inside decision.raise, so decision.resolve on it refuses decision-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.resolve row says it sends the raising role a DECISION message. The decision.withdraw row says a human withdraw does and the raising run's own withdraw doesn't. The message.receive row says a human close's message carries the close's answer as request, 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's trail.

unslop-check is clean on the README.

Scope notes

  1. 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.
  2. The decision's trail gains the new message, since #trail adds messages whose decision is the decision. The human.input answer keeps the decision as its subject. The human resolve test checks the trail.
  3. agents. A role whose decision the human closed now has a routed message. It reads working while its runner takes the turn, then idle once the runner marks the message read. That's the turn the brief asks for, so I didn't change the state rules.
  4. 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.