Files

15 KiB

Row 121 (#1615) round 2: projects and threads in the bus

Brief: docs/plans/2026-10-14_prd-1.2-build.md, section "Projects and threads in the bus". Round 1 is agents/darkwing/work/queue-121/r1 (packet ef8c3fe71). Dewey's verdict is changes, comment 28591. Sage's rulings are in sage-0347, sage-0348, sage-0360 and sage-0362.

Requirements: REQ-ORG-2, REQ-THREAD-1, REQ-THREAD-2, REQ-USER-2, REQ-ROLE-2, REQ-MSG-1, REQ-LAUNCH-1, and REQ-ASSIST-2 for F1. Test names carry the ids.

BASE: 20d2b57881 (row 132's landing). The candidate is build.patch against BASE, and candidate-manifest.sha256 lists its 30 files (sha256 of the manifest: 710d50866e77055b7231a53e3601bd2bab7686b722160f81ddda030875f67e9b). repro.sh exports BASE with git archive, applies build.patch and checks every manifest digest: DIGESTS_OK (repro-run.log).

Round 2 is a delta review (sage-0347). This file covers what changed since round 1. r1/BUILD.md still describes the design, the schema, the broker verbs and the round 1 tests, and none of that is repeated here.

What changed since round 1

  1. The rebase onto rows 49, 123, 133 and 132.
  2. Dewey's F1, F2, F3 and F5.
  3. The 3d→3e migration, its tests on a copy of a 3d database, and its mutants.
  4. Setup changes in two bus tests from rows 123 and 133, which a thread instance now needs. Sage accepted them in sage-0362, and they're listed below so Dewey can check them.
  5. land-work green on the rebased tree, with no borrowed hunk.

Dewey's findings (28591)

F1: thread.send runs the messaging check

thread.send now calls this.#checkMessaging(s.business, this.#sender(s), t.instance) before its insert, the same call message.send makes since row 133. A definition whose receiveFrom leaves out the human refuses message-sender-refused, and nothing is stored.

Test: messaging.test.mjs "REQ-ASSIST-2 REQ-THREAD-1 thread.send to an instance that does not hear from the user is refused and stores nothing". It gives the assistant receiveFrom: ['chief-of-staff'] and checks the refusal, the logged refusal, and that no message, no message_threads row and no human.input event was written. Mutant b-thread-send-unchecked removes the call.

F2: role.revoke on a thread run, documented

I documented it and didn't add a thread argument to revoke. A thread run already has three ways to stop: the idle stop, mosaic stop <run>, and the run's own end. A revoke verb for threads would mean a new decision context and a new claim path, which the row doesn't need.

What happens now, checked against the code:

  • At decision.raise the context's holderRun comes from the role claim. A thread instance has no role_claims row, so it is null.
  • Using the approved decision refuses decision-mismatch. #consumeAuthority compares that null with the current holder lookup, which is undefined, and they don't match.
  • The chief of staff's reason path refuses role-end-refused, because the assistant is protected (row 123's END_PROTECTED).
  • thread_revoke_needs_decision still guards thread_claims against a direct write.

The bus README's "Projects and threads" section says this. Test: threads.test.mjs "REQ-ROLE-2 REQ-THREAD-1: an approved role.revoke never reaches a thread run, and the thread stays held". It raises and resolves the decision, checks the decision-mismatch refusal, and checks that role_claims has no assistant row and the thread's last claim is still a-run's.

One edge I didn't test, recorded as a follow-up: a bus migrated from 3d could still hold a pre-3e role_claims claim for the assistant. A decision to revoke it would revoke that old role claim and touch no thread. Whether it can happen depends on whether the live bus has an open assistant claim at the window. Mos can check role_claims for one before the re-pin.

F3: no message strands at an idle stop

I took Dewey's first suggestion and made it atomic. The broker has a new launch op, threadIdle({business, thread, run}), reached through process.mjs and runtime.mjs like threadsState.

  • In one synchronous call it rereads the thread's pending and unread.
  • If either is nonzero it returns {released:false, pending, unread}.
  • Otherwise it releases the thread's claim and returns {released:true}.
  • It refuses unknown-run, run-ended, and not-holder for any run that isn't the thread's current holder, including another thread's holder and a non-thread run.

The launcher sends SIGTERM only after released:true. A message sent after that call has no holder, so it waits as pending and starts the next session. The released run can't take it: message.receive refuses not-holder.

Dewey's second suggestion, a new holder picking up messages delivered to a dead run, isn't in this round. A run that crashes mid-turn strands its delivered message, as row 80 does for any role. What I did fix is the count: unread now counts only deliveries to the current holder, so a dead run's message no longer keeps every later holder from idling out.

Tests:

  • threads.test.mjs "REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder": pending, then unread, each block the release; all four refusals; a refusal releases nothing; after the release the run can't receive the thread's next message, and the next holder gets it.
  • threads.test.mjs "REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder".
  • thread-launch.test.mjs "REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it". The test wraps the threadsState op so a thread.send lands between the snapshot and the threadIdle call. No thread-idle event is logged, the same run answers, and the next tick past the idle time stops it.

Mutants: b-idle-no-recheck, b-idle-ignores-pending, b-idle-ignores-unread, b-idle-no-holder-check, b-idle-no-ended-check, b-unread-any-holder, b-unread-no-claim-scope, p-thread-idle-op, l-idle-ignores-released, and l-never-idle-stop re-pinned to the new code.

Two launcher changes follow from this:

  • The tick's own pending === 0 && unread === 0 check is gone. The broker decides, and a copy of the check in the launcher would only hide a broker mutant. r1's l-idle-ignores-pending mutant targeted that check, so it's dropped.
  • A threadIdle refusal is logged on every tick it happens, not once per code. It means the launcher's registry and the bus disagree about a holder, which shouldn't last. If Dewey would rather have it deduplicated like refusedThreads, that's a small change.

F4: no action

An observation for when there's more than one user. Nothing changed.

F5: old unthreaded messages are kept and reported

The 3d→3e step changes no row. Messages from before 3e have no message_threads row, so a thread run never receives an undelivered one, and threadsState doesn't count it. I chose to keep them as written over dropping them or attaching them to a made-up default thread: messages are evidence, and a default thread would invent a conversation the user never started.

The step has a report. Store collects it in migrationReport, and migrate-schema.mjs prints one line per business and recipient role before "bus schema 3e ready":

kept 1 undelivered message(s) for demo/assistant without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send

The store doesn't read business files, so it can't tell which roles are thread instances. It reports every role, and the line says the loss applies only to a thread instance. A message to the chief or the human still delivers as before. My first wording said "a thread instance never receives them" on every line, which was wrong for the chief's and the human's lines. I fixed it before the gate.

Store's #verifyTimes now skips a timed table that doesn't exist yet, because the check runs on a 3d database before the step creates the thread tables.

Tests, thread-migration.test.mjs on a copy of a 3d database built from fixtures/schema-3d.sql:

  1. "REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and leaves messages from before threads as written": a 3d bus refuses without the step; with it, the version is 3e, the five tables are empty, the report matches, every message and delivery row is unchanged in both the bus and the copy, a second open reports nothing, and a thread run on the migrated bus receives none of the old messages and threadsState counts none.
  2. "REQ-THREAD-1: a 3d bus with a changed schema is refused and keeps its version and tables".
  3. "REQ-THREAD-1: the offline command prints the kept-message counts before the ready line".

The fixture covers waiting, delivered, read and failed deliveries, two businesses, and three recipient roles.

Mutants: m-report-counts-delivered, m-report-counts-all, m-report-by-role-only, m-report-dropped, m-cli-no-report and m-times-missing-table. A mutant that drops 'read' from the report's d.op IN ('delivered','read') is equivalent, because a read message always has a delivered row first, so it isn't in the set.

F6: the READMEs stay

Sage's ruling at landing, sage-0362: "Leave them in for now." The bus, cli and seat README edits stay in the candidate.

Changed tests outside this row's new files

Sage accepted these in sage-0362. Dewey, please check them on purpose.

  • packages/bus/tests/decision-route.test.mjs (row 133). The setup binds every role in a loop. The assistant now needs a thread, so the setup creates project demo and thread main as the human and binds the assistant with that thread. The business gains projects: { stack: { root: '/nonexistent' } }, which project.create needs. No assertion changed.
  • packages/bus/tests/chief-of-staff.test.mjs (row 123). The same setup change. In "REQ-COS-1: the chief of staff cannot end its own run or the assistant's through role.end", the assert on the assistant's last role_claims row is replaced by one on thread_claims: { op: 'claim', holder_run: 'assistant-run', by: 'assistant-run' }. A thread run's claim is in thread_claims, so the old query returned no row. The test still checks the refusal and that the claim is still held.
  • packages/business/tests/chief-of-staff.test.mjs. A different file from the bus one above. The one assert on the loaded record's launch now expects threadIdleSeconds: THREAD_IDLE_SECONDS next to launchDoc(INSTANCE), because loadBusiness fills in the default (REQ-THREAD-2). business.test.mjs has the same change to its fixture assert.
  • packages/bus/tests/comment-migration.test.mjs. The version pins move from 3d to 3e, the registry test's fixture-only future version goes from 3e to 3f, and 3d-3e.sql is copied with the other steps (Sage's note 28559).
  • packages/bus/tests/schema.test.mjs. 14 tables and 63 triggers: row 123's 9 and 39 plus this row's 5 and 24.

Other changes since round 1

  • The bus README said schema.sql is v3c, which was stale after row 123. It now says v3e.
  • thread-launch.test.mjs has 8 tests. The snapshot race test (F3) is the new one, after the long-turn test.

The rebase

Two steps. First onto 73fa83a08, which has rows 49, 123, 133 and 70. Then onto 20d2b5788 after row 132 landed (sage-0365).

On 73fa83a08:

  • store.mjs: SCHEMA_VERSION is 3e, and the 3d→3e step follows rows 49 and 123's steps in the same registry. r1's "provisional" comment is gone.
  • schema.sql: row 49's cursor table, row 123's revoke trigger, then this row's 3e block. migrations/3d-3e.sql is the 3e block alone, written against 3d.
  • broker.mjs: the F1 call uses row 133's #checkMessaging(business, from, to, refused) with #sender(s), as message.send does.
  • The decision-route and chief-of-staff test setups, listed above.
  • land-work: read.mjs imports the store's SCHEMA_VERSION since row 49, so the r1 failure at BASE is gone with no borrowed hunk.

On 20d2b5788: row 132 changed only broker.mjs line 99 (export const ORGANIZATION_KEYS) and one business README paragraph. I applied this round's diff with git apply --3way and both applied with no conflict. Line 99 and the row 132 paragraph are as landed. The six new files carried over unchanged.

Mutants

77 mutants ran from 18:31:50Z to 18:50:38Z on 20d2b5788 (mutants-run.log, one log per mutant in mutants/). All 77 were killed and none survived or failed to load. By target: SCHEMA 25, BUS 24, CLI 14, MIGRATION 6, SEAT 6, BUSINESS 2. The six control runs passed first with 0 fail: BUS 48, BUSINESS 26, CLI 26, MIGRATION 21, SCHEMA 28, SEAT 20. The run ends with restored: True. mutants.py --check gives 77 patterns. Every SCHEMA edit is mirrored on migrations/3d-3e.sql, because the store checks the step's target against schema.sql and an unmirrored edit loads as migration-target-mismatch instead of testing the trigger.

Gate

A targeted builder gate under decision 136 (gate.sh, logs in gate/). Sage's landing gate runs every suite. It ran on 20d2b5788 from 18:29:34Z to 18:31:35Z (gate-run.log). The four owned packages run in full, plus every package and script whose code or tests import a changed module. land-work is green from the rebase alone, and its run removed the worktree's Docker network.

Step Result
bus 214 pass, 0 fail, 0 skip
seat 135 pass, 0 fail, 0 skip
cli 203 pass, 0 fail, 0 skip
business 95 pass, 0 fail, 0 skip
harness 90 pass, 0 fail, 0 skip
plugins 21 pass, 0 fail, 0 skip
tasks 97 pass, 0 fail, 0 skip
webui 112 pass, 0 fail, 0 skip
scripts/test-land-work.sh 84 pass, 0 fail

How the round went

My land-work run overlapped Sage's row 132 gate. I had queued a land-work run behind a busy check. It started at 2026-10-15T18:19:39Z and finished at 18:19:52Z with 84 pass and 0 fail, while Sage held the slot. My busy check matched Sage's sage-g* gate directories but not /mnt/storage/scratch/sage-r132. I told Sage straight away, widened the check to every scratch/sage-* directory, and ran nothing else until Sage said clear. That run was on 73fa83a08, so it isn't part of this gate. The land-work row above is from the rebased tree.

Temp dirs. The gate and the mutant run left 529 dirs in my TMPDIR. 201 of the gate's 205 are mosaic-business-*, from packages/business/tests/helpers.mjs tmp(), which never removes what it makes. This row doesn't change that helper, so it stays as is, and I recorded it as a follow-up. The rest are mosaic-launch-* and mosaic-thread-* dirs, which t.after should remove. I didn't find out why some stayed. I deleted all of them after the run.

Going live

Unchanged from r1/BUILD.md, plus the migration. The re-pin needs bus downtime for 3d→3e (after 3b→3c→3d from rows 49 and 123) and Mos's go (decision 109). Read the kept-message lines the command prints: any undelivered message to the assistant from before the window has to be re-sent with thread.send, which needs row 128's CLI or row 11's chat.