Files
jason.woltjeandClaude Opus 5.5 08e327bf58 agents: Dewey row 146 build packet, a message to a stopped role tells launch.by and the sender (#1640)
Candidate manifest 616f953fe09bfc198645886bc4c7228a0fdeafa8c89b1729f2be8a5222689607 on dda7e31b0.
Owner's gate 23:11:36Z-23:15:46Z exit 0; mutants 17 of 17 killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:17:32 -05:00

14 KiB

Row 146 (#1640): a message to a stopped role tells the launcher and the sender

Builder: Dewey. Reviewer: Filbert. Gate owner: Sage. Brief: docs/plans/2026-10-15_q167-rows-r2.md, lines 32-110 (the preamble of 2026-10-15_q167-north-star-rows.md applies). Calls agreed in sage-0404; priority and the chief-of-staff.md fix in sage-0407.

Base: dda7e31b0c (base.txt, origin/refactor after rows 144 and 145). The candidate is build.patch on that base, and candidate-manifest.sha256 lists every changed path with its sha256. repro.sh applies the patch to a fresh detached worktree and checks the digests.

The calls (sage-0404: agreed, written as stated)

  1. No bus autostart of launch.by. talk prints a restart line instead. The launcher doesn't know whether the host was started with --pm, and a host without it means the operator chose no PM. An autostart spends tokens with no human command. A dedupe per waiting message would live only in host memory. It is also the smaller surface on the critical path. The autostart stays a follow-up. The line names the unit, mosaic-bus@<business>, because the live host runs as that unit installed with --pm. The README gives the hand-started form (mosaic bus stop, then mosaic bus start <business> --pm).

    Refined while building: bus start --pm is the only path that launches launch.by (packages/cli/src/launcher.mjs:467-472, launchPm). A unit installed without --pm restarts with no PM, so the line now says the restart starts it "if the unit runs with --pm" and names the check, scripts/bus-service.sh status <business>, which prints pm: on or pm: off.

  2. The notice's sender is from_role _bus, from_run bus. ID_PATTERN is /^[a-z0-9][a-z0-9._-]{0,63}$/, so no role instance can be named _bus. Plain bus would pass requireId and could collide.

  3. The notice's class is RESULT. See "Why RESULT" below; the reason as first stated was wrong in one detail, corrected there.

  4. No notice for a message to launch.by, or in a thread to a thread instance (the brief's two), or from launch.by (added). launch.by is told through message.send's result, and otherwise chief's own auto-RESULT to an ended run would wake chief again.

  5. The notice is sent only from message.send. review.request, thread.send and decision-close messages to a stopped role still wait silently. Follow-up.

Two more calls made while building:

  1. A tester's message tells nobody. A PM test (--tester) authorizes nothing, so it wakes no launcher. message.send's result still says the role isn't running, with told: false, and talk says "a tester's message tells nobody to launch it". A message to the tester, like one to the human, gets no waiting field: no run ever holds either.
  2. Dedupe is structural, not a store lookup. The brief says "deduplicated through the store". Only message.send calls #notice, once, for the message it has just stored, inside the verb's transaction (every message verb runs in this.#store.transaction). A re-route at claim (#routeWaiting), a release, a restart and a new claim never reach #notice, so a second notice for one message has no path. I wrote a store pre-check (SELECT ... WHERE from_role='_bus' AND in_reply_to=?) first and removed it: it could never find a row, so it was dead code no test could reach. The notice row itself is in the store, in_reply_to the waiting message, and test 3 checks the count across claim, release, restart and a new claim.

The change

packages/bus/src/broker.mjs

  • NOTICE_ROLE = '_bus', NOTICE_RUN = 'bus'.
  • message.send ends with #waiting(s, mid, a.to, thread). Its result is {id, request} as before when a run holds the target, and {id, request, waiting} when none does.
  • #waiting returns null for the human, the tester, a thread instance in a thread, or a target a run holds (#recipient). Otherwise it reads launch.by, decides told (a launcher exists, the target isn't it, the sender isn't it, the sender isn't the tester), sends the notice when told, and returns {role, launcher, told, launcherRunning}. launcherRunning is null with no launcher.
  • #notice inserts the message directly (it skips requireId, which _bus fails) and routes it to launch.by through #route, so a stopped chief gets it at claim like any waiting message. Body: Message <id> from <sender> to <role> waits: <role> has no running session. Launch <role> within the launch block's limits, or tell <sender> why not with message.send in_reply_to <id>.

packages/cli/src/cli.mjs — waitingLines, printed once after sent:

Case Line
no launch.by pm isn't running, and the business names no launch.by
target is launch.by the restart line for it
tester sender pm isn't running; a tester's message tells nobody to launch it
told, launcher running pm isn't running, chief has been told
told, launcher stopped the told line, then the restart line with "either"

Restart line: <role> isn't running[ either]. systemctl --user restart mosaic-bus@<business> starts it if the unit runs with --pm (scripts/bus-service.sh status <business> shows pm: on). The restart stops running sessions. The business is read only when a restart line prints.

roles/chief-of-staff.md

  • A duty after the Launch bullet: a message from _bus names a waiting message, its sender and the role. Launch the role within the launch block's limits, or tell the sender why not with a message in_reply_to the waiting message. Don't reply to _bus.
  • sage-0407 item 3: "raise a policy.change decision until the broker gives you a verb for the organization file" is replaced. Row 132 gave that verb, the change_organization tool (packages/harness/src/tools.mjs:151-152), which changes limits, the model table and each instance's model. Hiring an instance or changing what one may do still takes a policy.change decision, and the sentence now says so.

READMEs. packages/bus/README.md: the message.send row and a "Launch notices" section. packages/cli/README.md: talk's lines, the restart command with its --pm condition, the hand-started form, and that no verb launches launch.by on a running host. docs/TOOLS.md has no talk output detail, so it is unchanged.

No schema step. messages.class has no CHECK constraint, and RESULT is an existing class.

_bus stays internal (sage-0404)

#notice is private and has one caller, #waiting, inside message.send. No verb takes a sender. launch-notice test 7 checks:

  • ID_PATTERN rejects _bus, so no business file can declare it.
  • For the human and a role (coder), message.send with _bus in each field a caller controls: to refuses unknown-role; from_role and from_run refuse invalid-request (unknown fields); class refuses invalid-class; in_reply_to and corrects refuse invalid-request; decision refuses decision-not-found.
  • chief replying to _bus with in_reply_to the notice refuses unknown-role. chief's answer to the sender stores from_role chief, and the notice count stays 1.

Why RESULT, and where the test lives (sage-0404)

harness/src/runner.mjs:386-397: for a message whose class isn't RESULT the runner auto-replies with message.send to m.from_role, then calls message.read in the same try. If the reply throws, it logs runner: reply to <id> failed and skips the read.

Correction to call 3 as first stated. I said a non-RESULT notice would be "processed again: a turn loop". That is wrong. message.receive returns only messages with no delivered or read row, and the notice is delivered once it is received, so it is never received again. What a non-RESULT class would do: the auto-reply to _bus refuses unknown-role, the runner logs the failure and skips the read, and the notice stays unread for good. No loop, but an error per notice and an unread message in chief's inbox. With RESULT, chief takes one turn, sends no auto-reply, and reads it.

The test is at runner level, in packages/harness/tests/runner.test.mjs: "the bus's launch notice is one turn with no reply, and is not taken again". The runner runs as chief (the fixture sets launch.by: chief), the human sends to coder, and the test waits for the notice to be read. pm then sends chief a request, and its reply arrives, so the runner has taken another pass. It asserts: the turns dir is exactly 0001.stderr, 0002.stderr (one turn for the notice, one for pm); the notice has exactly one delivered and one read row; chief sent nothing in reply to the notice; stderr has no reply to ... failed. Mutant B10 (class INFO) is killed by this test.

Readers of _bus (sage-0404)

Each renders from_role as text with no role lookup, so a _bus sender renders without an error. Lines checked on the candidate:

  • mosaic read/follow: packages/cli/src/cli.mjs:212 (formatMessage) and packages/cli/src/format.mjs:78.
  • The Console: packages/webui/src/public/bus.js:174-176 and 181 (trail and grouping; a non-human sender becomes actor: {role, run}), 267 (a message's replies, so the notice shows under the waiting message, since it is in_reply_to it), 321 (a role's waiting list).
  • The runner: packages/harness/src/runner.mjs:65 (the turn's header line), 75 (the tester check, a string compare).

No change was needed in any of them.

Tests

  • packages/bus/tests/launch-notice.test.mjs, new, 7 tests. Fixture: chief, assistant (the shipped messaging lists, as threads.test.mjs does), pm, coder; launch.by: chief; bus.tester: true.
    1. A message to a stopped role: exactly one notice, every field and the full body checked, its delivery waits with no holder; chief receives it at claim; with chief running, launcherRunning is true; a role sender (coder) tells chief too.
    2. A message to a running role: no notice, result keys exactly id, request.
    3. A re-route at claim, a release, a broker restart and a new claim: still one notice, and the new run receives the waiting message.
    4. Launched but not claimed counts as no holder (brief: accepted, documented in the README).
    5. No notice to launch.by, from launch.by, from a tester, to the human, to the tester (a tester ping, then coder's reply in_reply_to it), or a thread reply to the assistant.
    6. No launch block: {launcher: null, told: false, launcherRunning: null}, no notice.
    7. _bus internal, above.
  • packages/harness/tests/runner.test.mjs, one new test, above. setup takes launchBy.
  • packages/cli/tests/verbs.test.mjs, "talk says when the role isn't running, whether launch.by was told, and how to start launch.by": the no-launch-block line; nothing for a running coder; with launch.by pm, cto gets the told line and pm's "either" restart line; pm gets its own restart line and the _bus count stays 1; once pm is claimed, cto gets the told line only. helpers.mjs broker() takes a businesses argument for it.
  • packages/cli/tests/tester.test.mjs, "talk --tester to a stopped role says nobody is told to launch it": the exact output and a _bus count of 0.

The brief's list: one notice (1), none for a running role (2), none at re-route and restart (3), both waiting lines and the restart line for launch.by (verbs).

Row 142

Row 142 (Researcher) also changes talk in cli.mjs and verbs.test.mjs. It had not landed at dda7e31b0. Whichever row lands second rebases on the other. My cli.mjs change is one function before the talk comment and one line after the sent print.

Follow-ups (not in this row)

  • Notices from review.request, thread.send and decision close, which still wait silently (call 5).
  • The bus starting launch.by itself (call 1), through the launcher tick and launchPm.
  • roles/chief-of-staff.md's Protocol section still says "You have no write tool" and lists only the launch and message tools; row 132 added change_organization. Researcher's row 140 edits the same file, so I left it for 140.

Receipts

  • repro.sh into a fresh worktree: DIGESTS_OK.
  • Every test file the candidate adds or changes (sage-0413), on the candidate at 23:05:29Z, all 0 fail and 0 skipped: bus launch-notice 7, cli tester 7, cli verbs 7, harness runner 24. cli/tests/helpers.mjs is not a test file; every cli test imports it and the gate runs them all.
  • Other singles before that, 0 fail: bus messaging 13, threads 14, tester 11, broker 16; business chief-of-staff 10.
  • Base check: origin/refactor is dda7e31b0. No commit in dda7e31b0..HEAD touches a manifest path.

The slot was granted by Sage in sage-0417 at 23:06:21Z.

  • Mutants: run from 2026-10-15T23:06:30Z to 23:11:24Z. 17 of 17 killed, and the source was restored; DIGESTS_OK afterwards. Details are in MUTANTS.md.

  • Gate: gate.sh run from ~/dewey-scratch/r146, 2026-10-15T23:11:36Z to 23:15:46Z, exit 0. The output is in gate-run.log and the logs are under gate/. It ran the touched packages and every package that imports the bus, then every scripts/test-*.sh:

    Step Result
    bus 259 pass, 0 fail
    business 106 pass, 0 fail
    cli 209 pass, 0 fail
    harness 101 pass, 0 fail
    plugins 28 pass, 0 fail
    seat 135 pass, 0 fail
    tasks 97 pass, 0 fail
    webui 113 pass, 0 fail
    test-auth 15 passed, 0 failed
    test-conductor 17 passed, 0 failed
    test-config 24 passed, 0 failed
    test-discord 78 passed, 0 failed
    test-extension-package 18 passed, 0 failed
    test-foundation 44 passed, 0 failed
    test-land-work 84 passed, 0 failed
    test-queue 28 passed, 0 failed
    test-release 14 passed, 0 failed
    test-task 98 passed, 0 failed

    Flakes #258, #261, #262 and #263 did not recur. The packages that don't import the bus (control-board, conversation, discord, ledger, mosaic, runs) are left to Sage's full gate. business runs here because its chief-of-staff test reads roles/chief-of-staff.md.

  • Docker: the gate left no network. The only wt_default network on the host was created at 17:42Z, before this gate started, so I left it in place.