Files
jason.woltjeandClaude Opus 5.5 ef8c3fe713 agents: Darkwing row 121 round 1 packet (candidate manifest, gate, 62 mutants)
Round 1 is a code and test review and won't land. land-work fails at
BASE on the read.mjs 3b pin, which row 49 removes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:35:28 -05:00

31 KiB

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

Brief: docs/plans/2026-10-14_prd-1.2-build.md, section "Projects and threads in the bus" (blob 333adea85 at HEAD). Design: comment 28555. Sage approved it with changes in comment 28556 and sage-0308. Sage granted the slot in sage-0331 and sage-0332.

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

BASE: eaeb5ebf2 (row 131's landing). The candidate is build.patch against BASE, and candidate-manifest.sha256 lists its 23 files. repro.sh exports BASE with git archive, applies build.patch and checks every manifest digest: DIGESTS_OK at 2026-10-15T17:30:24Z (repro-run.log).

Round 1 is a code and test review, and it won't land. The last section lists what round 2 adds.

This round has no migration. The approved order is 3b→3c (row 49), 3c→3d (row 123), then 3d→3e here. Rows 49 and 123 haven't landed, so the 3d→3e migration, its test on a copy of a schema-current database and its mutants come in round 2, after I rebase onto them. Until then SCHEMA_VERSION = '3e' in store.mjs is provisional, and a fresh database gets 3e directly. Dewey can review everything else now. The brief's first acceptance item stays open until round 2.

File Change
packages/bus/schema.sql the 3e block: 5 tables and 24 triggers
packages/bus/src/store.mjs SCHEMA_SHA256, SCHEMA_VERSION in place of the two hardcoded '3b', 4 timed tables
packages/bus/src/broker.mjs thread claims, routing and receive, 7 human verbs, threadsState, the thread key on bindLaunch
packages/bus/src/process.mjs the threadsState launch op
packages/bus/src/runtime.mjs threadsState passthrough
packages/seat/src/session.mjs threadStore(), the thread key in Registry.running
packages/seat/src/sandbox.mjs the thread store's two binds, the projects mount point in prepareRun
packages/cli/src/launcher.mjs the thread check, the thread store as sessionDir, the start and idle-stop tick
packages/business/src/business.mjs THREAD_IDLE_SECONDS, the optional launch.threadIdleSeconds
packages/bus/README.md, packages/cli/README.md, packages/seat/README.md, packages/business/README.md the new tables, verbs, store and setting
packages/bus/tests/threads.test.mjs new, 11 tests
packages/cli/tests/thread-launch.test.mjs, packages/cli/tests/fixtures/thread-adapter.mjs new, 7 tests and their stub adapter, which has a slow turn
packages/bus/tests/messaging.test.mjs, schema.test.mjs, end-launch.test.mjs adjusted, see "Existing tests"
packages/seat/tests/session.test.mjs, sandbox.test.mjs 3 new tests
packages/business/tests/business.test.mjs, chief-of-staff.test.mjs test 10, and the default in two deepEquals

The ownership note in the claim lists the business README but not the bus, cli and seat READMEs. I edited those three to document the new tables, verbs, tick and store. Say if you want them split out.

The 28556 changes

# Change Where Tested by
1 delivery_same_thread applies only when m.to_role = t.instance, so the assistant's messages to the chief route normally schema.sql, the trigger's WHEN threads.test.mjs "the chief of staff's reply comes back": the assistant's message reaches the chief through the broker. Test 5 keeps its direct insert. Mutant s-delivery-unscoped
2 thread.create refuses thread-instance-missing with no thread instance and thread-instance-ambiguous with more than one broker.mjs thread.create "thread.create refuses when the business has no thread instance or more than one". Mutants b-thread-instance-missing and b-thread-instance-ambiguous
3 both segments are checked against ID_PATTERN with no .. before the join, then each directory gets the safe() checks session.mjs threadStore() session.test.mjs threadStore, and thread-launch.test.mjs test 3 with a hand-made id. Mutants seat-segment-dotdot, seat-segment-pattern, seat-store-dir-check, seat-store-mode-check
4 the refusal is thread-required-reply-to-thread-message, and the chain walk stops on a seen set with no depth constant broker.mjs #threadFor "the chief of staff's reply comes back", with a long chain that never loops. Mutants b-chain-one-step and b-chain-depth-constant
5 the Claude resume smoke is in the 3e window plan with Mos nothing in this row none. No seat runs it

On 3: ID_PATTERN is /^[a-z0-9][a-z0-9._-]{0,63}$/, so it already excludes / and a leading .. The extra .. check catches a..b, which the pattern allows.

On 4: a code is the whole message a run sees, because runner.mjs forwards codes only. So the code itself says what to do. A human message.send to the assistant gets its own code, thread-required-use-thread-send.

The answers in 28556

  1. Order: 3d→3e after rows 49 and 123, with row 49's version scheme. Nothing in this round depends on it except the provisional SCHEMA_VERSION.
  2. launch.threadIdleSeconds is optional. Absent means THREAD_IDLE_SECONDS, 900, exported once from business.mjs. A present value goes through requirePositiveInt with no upper bound, and the key is accepted when no thread instance is declared. No org file changed.
  3. The projects→repos rename is Sage's P19 follow-up. project.create still takes repo, checked against the business file's projects, and refuses unknown-repo.

The code

Schema (3e)

Five tables: projects, threads, thread_claims, message_threads and thread_opens. Side tables, not a thread column on messages and deliveries, as the design said, so the migration adds tables and rewrites none.

Nine behavior triggers:

Trigger Refuses
threads_project_business a thread whose project is in another business
thread_one_holder a claim on a thread someone holds
thread_one_per_run a claim by a run that holds another thread
thread_release_by_holder a release or revoke of a thread nobody holds, or a release by a run that isn't the holder
thread_release_same_run a release or revoke that names a different run than the holder
thread_revoke_needs_decision a revoke with no decision
thread_claims_business a claim in a business other than the thread's
message_threads_match a thread on a message that isn't to or from the thread's instance
delivery_same_thread a delivery of a thread message to the instance by anyone but that thread's holder

Fifteen append-only triggers: no_update, no_delete and no_replace on each of the five tables. In all the schema has 13 tables and 59 triggers, up from 8 and 35.

Broker

  • A role whose definition is assistant is the thread instance (THREAD_DEFINITION). Its runs bind with a thread, claim that thread instead of the role, and receive only that thread's messages. bindLaunch refuses thread-required for a thread instance with no thread, thread-not-found for a thread of another instance or business, and thread-not-allowed for any other role that names one. The session.launched body gets thread only when one is set, so an existing run's body is unchanged and a rebind still matches.
  • role.claim for a thread run writes thread_claims, refuses run-revoked and thread-already-held, and routes the thread's waiting messages to the new holder. #claimEnd, endLaunch, delivered and read use the thread claim through #own.
  • Every message is placed in a thread or not by #threadFor. A thread run's message is in its thread. A message to the instance from anyone else follows in_reply_to up to the nearest message that has a thread, and refuses thread-instance-mismatch if that thread belongs to another instance. A decision close to the assistant goes to the thread the raising run held.
  • Seven human verbs: project.create, project.list, thread.create, thread.list, thread.open, thread.send and thread.messages. #thread refuses thread-not-found and thread-not-owned, which is REQ-USER-2 with the one user there is today. Agents get none of these verbs.
  • thread.messages pages oldest first, 50 by default. THREAD_PAGE_MAX is 200. That is a page size, so a call returns at most 200 rows and the caller pages on with after. It isn't a cap on threads or messages, which is what Q150 rules out, but if Dewey reads it as a ceiling it can go.
  • threadsState(business) is a trusted launcher API, like bindLaunch, reached through the process op threadsState. Per thread it returns pending (waiting for delivery), unread (delivered, not read, so a turn in progress), and the latest message, open and claim times. I named it after the launch ops' camelCase. The design called it threads.state.

Seat

threadStore(dataRoot, business, thread) makes threads/<business>/<thread>/ with session/ and claude-projects/. It checks both segments first. Then for every directory from threads/ down it refuses one that is a symlink, isn't a directory, isn't ours, or has any group or other bit. The symlink check is redundant on Linux, where a symlink's lstat mode is 0777 and the mode check catches it, but it states the intent and costs nothing. A failure throws SeatError.

binds() adds the store's session/ read-write, and claude-projects/ read-write at the run's home/.claude/projects. That bind is the only new host path, which is why decision 130 gives the review to Dewey. Registry.running takes thread as part of its key.

Launcher

  • check() refuses thread-required and thread-not-allowed the same way the broker does, so a session or --pm launch of the assistant fails before a run directory exists.
  • start() calls threadStore() first and refuses thread-store-refused on any error. A thread run's sessionDir is the store's session/.
  • tick() runs every THREAD_TICK_MS, 2 s. It's a poll period. The tests set threadTickMs. Each tick reads threadsState and:
    • stops a thread session whose thread has nothing pending, nothing unread, and no message or open for threadIdleSeconds, measured from the latest of the run's start, the last message and the last open. The stop is SIGTERM to the runner after a start-time check, the same way close() stops a run.
    • starts a session for a thread with no holder that has a pending message or an open newer than its last claim. The start goes through check(), so it refuses like any launch.
  • Two guards stop a loop. startedFor keeps a signature per thread, lastMessage|lastOpen, and a thread doesn't start twice for the same one. A session that died before claiming waits for the next message or open instead of restarting every 2 s. refusedThreads logs a refusal once per code per thread.
  • host.mjs is unchanged. The launcher polls the broker rather than the broker pushing to it.

THREAD_DEFINITION appears in both broker.mjs and launcher.mjs. The launcher doesn't import from packages/bus/src, so I copied the constant with a comment naming the other one rather than add that import.

Tests

The brief's test list, and where each item is:

Brief item Test
one holder per thread threads.test.mjs "one holder per thread, through the broker and the trigger"
a message names its thread threads.test.mjs "a message to or from the assistant names its thread, or is refused"
the chief's reply comes back to the thread that sent the work threads.test.mjs "the chief of staff's reply comes back to the thread that sent the work"
history survives an idle stop and a restart (stub harness) thread-launch.test.mjs test 1, and test 3 for a turn longer than the idle time
one thread's messages never reach another threads.test.mjs "one thread's messages never reach another thread's holder", and thread-launch.test.mjs test 1 for the stores
an idle thread holds no session and counts against no limit thread-launch.test.mjs tests 1 and 2

threads.test.mjs, 11 tests, bus only:

  1. "one holder per thread, through the broker and the trigger": a second claim on a held thread refuses thread-already-held and, inserted by hand, hits the trigger. One run can't hold two threads. A released thread takes a new run, and endLaunch releases.
  2. "the 3e triggers refuse writes that skip the broker": a bare database built from schema.sql, so only the triggers are in the way. One case per behavior trigger, and UPDATE, DELETE and INSERT OR REPLACE on each 3e table.
  3. "a message to or from the assistant names its thread, or is refused": the chief's unthreaded send, the user's message.send, a bind with no thread and a bind of another role with one all refuse with their codes. Accepted messages name their thread, including a decision close.
  4. "the chief of staff's reply comes back to the thread that sent the work": two threads each ask the chief, the work goes through the coder, and each reply lands in its own thread. It also covers the assistant's message reaching the chief through the broker (change 1), a 400-step chain with no loop (change 4), and the refusal for a chain with no thread in it.
  5. "a reply can't carry one thread instance's thread to another thread instance": a business with a second thread instance, its thread made by hand because thread.create refuses two. The chief's reply to the assistant's thread message, sent to the other instance, refuses thread-instance-mismatch and writes no message. Added after the first mutant run, see "Mutants".
  6. "one thread's messages never reach another thread's holder": B's holder gets none of A's messages, a delivery inserted by hand is refused by the trigger, and B can't read A's message.
  7. "a message to a thread with no holder waits, and the next holder gets it", and thread.open records an open with no message.
  8. "the human verbs refuse another owner's thread": REQ-USER-2 on every thread verb, and no agent gets a thread verb.
  9. "thread.create refuses when the business has no thread instance or more than one" (change 2).
  10. "projects and threads are named once, and the repo names a business project".
  11. "thread.messages returns the thread's history in pages and writes nothing".

thread-launch.test.mjs, 7 tests, real runners in the bwrap sandbox with a stub adapter (fixtures/thread-adapter.mjs) that keeps a history file in its session directory and answers with what it found there:

  1. A message starts the thread's session, and the session stops when the thread goes idle. A second message starts a new run that finds the first run's history. A new host and launcher on the same data root find it too. Thread B's store is empty and has none of A's messages. An open with nothing pending starts a session that idles out.
  2. A running thread counts against its model's limit, and an idle-stopped one doesn't. With a limit of 1, B starts only after A's run ends, and B's refusal is logged once.
  3. A turn longer than the idle time isn't stopped: the stub's "slow" turn takes 3 s against an idle time of 1 s, the reply comes back, and then the thread idles out.
  4. A thread held by a run the launcher didn't start, bound by hand as after a restart before recovery, gets no session when a message comes in.
  5. A run that ends without taking the message it was started for isn't started again for that message. The test claims the thread by hand between the start and the run's own claim, so the run's claim refuses and it exits 21. Once the hand claim is released, three ticks start nothing.
  6. A thread with a hand-made id that isn't a safe segment, and a thread of a non-thread instance, launch nothing. More ticks log nothing more.
  7. The chief can't launch the assistant outside a thread.

Tests 3 to 5 were added after the first mutant run. Test 5 depends on ordering: tick() returns once the run is bound and its capability is queued on its stdin, and the test's claim is a synchronous broker call, so it lands before the run can read its capability. That test runs with the interval tick off (tickMs of an hour). I ran tests 3 to 5 five more times on their own, and all passed.

The seven skip only when the sandbox can't run (sandboxProblem()). They didn't skip in any run below.

seat: "Registry.running keys a thread run by business, instance and thread", "threadStore checks both segments before the join and refuses an unsafe directory" (.., a/b, .x, a..b, a business of .., "", Th, 7 and undefined, then a directory at mode 755 and a symlink), and "a thread store binds its session directory and Claude transcripts, and nothing more".

business: "launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses". This is test 10 from the design, with 0, -1, 1.5, "900" and null refused and Number.MAX_SAFE_INTEGER accepted.

Existing tests

  • schema.test.mjs: the counts go to 13 tables and 59 triggers.
  • messaging.test.mjs: the assistant binds with a thread, and the user writes to it with thread.send. The chief's and the pm's sends to the assistant now answer a message in the thread. These follow from REQ-MSG-1. Every message to the assistant must name its thread.
  • end-launch.test.mjs: the process test asks the child for threadsState on a hand-made thread.
  • business.test.mjs and chief-of-staff.test.mjs: a checked launch now carries threadIdleSeconds: 900.

Mutants

mutants.py runs every set on the unmutated source first and stops unless each run has rc 0, 0 failures and 0 skipped. Each mutant checks that its pattern occurs exactly once (--check: 62 patterns) and that every test file its set names exists, so a missing file can't count as a kill. The 24 trigger mutants replace the trigger's RAISE(ABORT, ...) with NULL and rewrite SCHEMA_SHA256 to match, so a kill is the test and not the store's digest check. A run that prints schema-source-mismatch counts as LOAD-FAIL, not killed.

62 mutants. The first batch killed 57. Five survived: b-instance-mismatch, b-state-holder-null, l-idle-ignores-pending, l-start-while-held and l-no-signature-guard. Each showed a check with no test, so I added the tests rather than drop the mutants: threads.test.mjs test 5 and thread-launch.test.mjs tests 3 to 5. A rerun of those five, after a fresh control run on the unmutated source (bus 44/0, cli 25/0), killed all five. Every source file was restored after each batch (restored: True in mutants-run.log). The table shows each mutant's final result and the first test that fails on it. mutants/ has every run's log.

Mutant Set Result First failing test
t-threads_project_business SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_one_holder SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
t-thread_one_per_run SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
t-thread_release_by_holder SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_release_same_run SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_revoke_needs_decision SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_claims_business SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-message_threads_match SCHEMA killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
t-delivery_same_thread SCHEMA killed REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
t-projects_no_update SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-projects_no_delete SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-projects_no_replace SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-threads_no_update SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-threads_no_delete SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-threads_no_replace SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_claims_no_update SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_claims_no_delete SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_claims_no_replace SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-message_threads_no_update SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-message_threads_no_delete SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-message_threads_no_replace SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_opens_no_update SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_opens_no_delete SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
t-thread_opens_no_replace SCHEMA killed REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
b-human-must-use-thread-send BUS killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
b-instance-mismatch BUS killed (rerun) REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
b-chain-one-step BUS killed REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
b-chain-depth-constant BUS killed REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
b-no-thread-returns-null BUS killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
b-thread-not-owned BUS killed REQ-USER-2: the human verbs refuse another owner's thread
b-bind-thread-required BUS killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
b-bind-thread-not-found BUS killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
b-bind-thread-not-allowed BUS killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
b-thread-already-held BUS killed REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
b-receive-any-thread BUS killed REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
b-thread-instance-missing BUS killed REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
b-thread-instance-ambiguous BUS killed REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
b-state-pending-zero CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
b-state-last-open-null BUS killed REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
b-state-holder-null CLI killed (rerun) REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
p-threads-state-op BUS killed broker process: launch ops authorize role.launch, record refusals and end runs
s-delivery-unscoped SCHEMA killed REQ-MSG-1: a message to or from the assistant names its thread, or is refused
seat-segment-dotdot SEAT killed REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory
seat-segment-pattern SEAT killed REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory
seat-store-dir-check SEAT killed REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory
seat-store-mode-check SEAT killed REQ-THREAD-2 REQ-LAUNCH-1 threadStore checks both segments before the join and refuses an unsafe directory
seat-registry-thread-key SEAT killed REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread
seat-no-thread-binds SEAT killed REQ-THREAD-2 a thread store binds its session directory and Claude transcripts, and nothing more
l-check-thread-required CLI killed REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
l-check-thread-not-allowed CLI killed REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, laun...
l-no-thread-store CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-session-in-run-dir CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-never-idle-stop CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-idle-ignores-pending CLI killed (rerun) REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
l-idle-fixed-900 CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-open-never-starts CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-never-start CLI killed REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; it...
l-start-while-held CLI killed (rerun) REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
l-no-signature-guard CLI killed (rerun) REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
l-refusal-every-tick CLI killed REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
biz-idle-not-checked BUSINESS killed REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anyt...
biz-idle-default BUSINESS killed REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anyt...

Gate

A targeted builder gate under decision 136 (gate.sh, logs in gate/). Sage's landing gate stays full. The four owned packages run in full, plus every package and script whose code or tests import a changed module.

Step Result
bus 182 pass, 0 fail, 0 skip
seat 135 pass, 0 fail, 0 skip
cli 202 pass, 0 fail, 0 skip
business 93 pass, 0 fail, 0 skip
harness 84 pass, 0 fail, 0 skip
plugins 21 pass, 0 fail, 0 skip
tasks 92 pass, 0 fail, 0 skip
webui 112 pass, 0 fail, 0 skip
scripts/test-land-work.sh 26 pass, 58 fail at BASE. Not a pass. See below

land-work fails at BASE because of the read.mjs pin. scripts/land-work/read.mjs has its own const SCHEMA_VERSION = '3b', and its schema check refuses any bus whose meta version isn't that literal. This candidate's store writes 3e, so every selftest that opens a bus refuses bus-schema ("meta says version 3e"). gate/land-work.log has the run. read.mjs isn't in this row, and row 49 replaces the literal with an import of the Store's SCHEMA_VERSION. To check that the pin is the only cause, I applied row 49's read.mjs hunk (logs/row49-read.patch, taken from agents/rocko/work/queue-49/r1/build.patch) for one run: 84 pass, 0 fail (gate/land-work-with-row49-read.log). Then I restored read.mjs and checked its sha256 (550b746de4e3d7778cd18a37b566034a8bc64e41e57c002fc0578a7a6ae26d9b). That run shows the cause. It isn't a pass for this candidate, and land-work goes green in round 2, from the rebase onto row 49.

The gate stopped at land-work, so no step after it ran in this gate.

How the round went

Two things Sage asked me to record.

The three new files went missing after the rebase. I rebased onto eaeb5ebf2. The untracked files, threads.test.mjs, thread-launch.test.mjs and its fixture, didn't come through. My first run after the rebase gave 151 pass, 0 fail, and I almost took it as green. node --test skips a file path that doesn't exist without a word. I restored the three files from my backup tar and reran: 164 pass, 0 fail. gate.sh and mutants.py now check that each named test file exists before they run.

business.mjs conflicted on TOP_KEYS. Row 131 added plugins to TOP_KEYS. My commit had added the THREAD_IDLE_SECONDS constant on the lines just above it, so the two hunks touched. I kept both: the constant, then row 131's TOP_KEYS with plugins, unchanged. threadIdleSeconds is a launch key, and it goes in checkLaunch's key list, which didn't conflict. My slot message to Sage said I "added the threadIdleSeconds key" to TOP_KEYS. That was wrong. I added the constant next to it, and TOP_KEYS is exactly row 131's.

Leftovers. sweep.log, at 2026-10-15T17:29:40Z after the mutants, the repeat runs and the gate: 0 processes had a cwd under my TMPDIR. The test runs had left 1385 mosaic-business-*, 72 mosaic-launch-* and 28 mosaic-thread-* directories under my TMPDIR; I removed them and 0 remained. No Docker network exists for this worktree, and no *.orig file is left.

What round 1 is, and what round 2 adds

Round 1 is a code and test review. It won't land (sage-0343). Round 2 adds:

  1. A rebase onto rows 49 and 123 once they land.
  2. The 3d→3e migration step in packages/bus/src/migrations/, written against 3d, with its test on a copy of a schema-current 3d database that the bus then loads, and a mutant per check.
  3. The version pins in comment-migration.test.mjs moved on for 3e, the same kind of edit row 123 makes for 3d (Sage's note 28559).
  4. land-work green from the rebase, not from a borrowed hunk.
  5. A rerun of the targeted gate and the migration mutants, with the slot.

Round 2 is a delta review: Dewey checks the rebase, the migration and the gate.

Going live

This round can't go live: it has no migration, and the live bus is at 3b.

When it does, it changes behavior for the assistant. The broker refuses a launch of the assistant without a thread, and a human message.send to the assistant refuses thread-required-use-thread-send. The user then needs thread.create and thread.send, and the CLI for them is row 128, the WebUI chat row 11. A tester send to the assistant refuses the same way until those land. I didn't read Jason's org file. I assumed the assistant role there uses the assistant definition, the way roles/assistant.json is shipped.

A thread run gets its own session store, but it shares the instance's workspace with the business's other thread runs. Two threads open at once write into one workspace. The design chose this to keep the repos checked out once. A per-thread workspace is a later change if it matters.

The Claude resume smoke runs in the 3e window plan with Mos. Going live is a re-pin on Mos's go (decision 109), with bus downtime for the migration.