Candidate on base 94ece491f: 5 files, manifest 1910eb5a; only
decision-close.test.mjs changed since round 1. Mutants m1-m4 killed
(m4 is Filbert's f4); gate passed with no flakes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
131 lines
12 KiB
Markdown
131 lines
12 KiB
Markdown
# Row 115 (#1609) round 2: a closed decision sends the raising role a message
|
|
|
|
## Round 2
|
|
|
|
Filbert asked for changes in round 1 (comment 28422, rev 1131). Their C1: nothing tested the op filter in `#request`'s fallback query, `json_extract(body,'$.op') IN ('resolved','withdrawn')`. A human `decision.seen` on a human-routed decision also writes a `human.input` answer with the decision as its subject, with op `seen`. Without the filter, a seen before the close becomes the close message's `request`, which breaks brief item 3. Their f4 dropped the filter and the suites still passed.
|
|
|
|
The fix is test-only, and broker.mjs is the same as in round 1. In packages/bus/tests/decision-close.test.mjs:
|
|
|
|
- A new test, "a human seen before the close leaves the request on the close's answer, not the seen". The human sees two human-routed decisions, then resolves one and withdraws the other. Each close message's `request` is that close's own answer, and not the seen's.
|
|
- The `answer()` helper takes an op, `resolved` by default, so it can't return a seen's answer either. The human withdraw test now asks for `withdrawn`.
|
|
|
|
f4 is m4 in mutants.py. Round 1 sections below are updated where the counts changed.
|
|
|
|
|
|
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: 94ece491f05ecbb737227da8a7ba1d57a1ac75fd. 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, of which only decision-close.test.mjs changed since round 1. I applied the patch to a fresh `git archive 94ece491f` export and every digest matched (DIGESTS_OK). HEAD has moved past 94ece491f since, with docs commits and the PRD v1.1 merge f7c8537b3. That merge changes docs/prd/mosaic-stack.md, packages/tasks/requirements.json and two requirements tests, packages/tasks/tests/requirements.test.mjs and packages/bus/tests/requirements.test.mjs (2 lines). None of them is a manifest path, but the bus one is in the bus suite, so the landing gate at the new target covers it and mine at 94ece491f doesn't.
|
|
|
|
## 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 8 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 withdraw's answer |
|
|
| a human seen before the close leaves the request on the close's answer, not the seen | round 2. Two human-routed decisions, both seen by the human, one resolved and one withdrawn. Each message's `request` is its close's answer, not the seen's |
|
|
| 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 (156 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, 8 fail: the seven close tests that expect a message, plus single-use's DECISION count |
|
|
| m2 | `to_role` becomes `d.route_to` | killed, 7 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 |
|
|
| m4 | Filbert's f4: `#request`'s fallback loses `AND json_extract(body,'$.op') IN ('resolved','withdrawn')` | killed, 1 fails: the round 2 seen-before-close test |
|
|
|
|
All four were restored, and `git diff --stat` matched the candidate afterwards. Round 1's m1 to m3 were killed too, with one fewer failing test each where the round 2 test applies.
|
|
|
|
## Gate
|
|
|
|
gate.sh runs every package suite and every scripts/test-*.sh against the candidate worktree. GATE_PASSED at 17:00:04Z, 16:54:23 to 17:00:04. Log: gate-run.log, per-step logs under gate/. Round 1's gate also passed, at 16:43:58Z.
|
|
|
|
| Step | Pass |
|
|
|---|---|
|
|
| bus | 156 (148 at BASE, plus the 8 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).
|
|
|
|
Managed sessions. Sage saw a managed coder session run tests in its workspace at about 16:30Z, during their row 113 gate. My busy check flags test runners started from any process whose cwd is under ~/.mosaic-dev/workspaces/. In round 2 a managed PM turn (`claude -p`, cwd ~/.mosaic-dev/workspaces/mosaic-stack/pm) was live at 16:53Z. The check first flagged it because the prompt text in its command line contains "test". Its processes were sh, claude and node, with no test runner, so I narrowed the check to test runner command lines and went ahead. The check was clear before the mutants and before the gate. A scan after the gate found no process with a cwd under the workspaces. I can't rule out a short run between checks, but nothing failed.
|
|
|
|
The release step created the `wt_default` Docker network at 16:59:12Z. 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.
|