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]>
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
- 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. launch.threadIdleSecondsis optional. Absent meansTHREAD_IDLE_SECONDS, 900, exported once from business.mjs. A present value goes throughrequirePositiveIntwith no upper bound, and the key is accepted when no thread instance is declared. No org file changed.- The projects→repos rename is Sage's P19 follow-up.
project.createstill takesrepo, checked against the business file'sprojects, and refusesunknown-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
assistantis the thread instance (THREAD_DEFINITION). Its runs bind with athread, claim that thread instead of the role, and receive only that thread's messages.bindLaunchrefusesthread-requiredfor a thread instance with no thread,thread-not-foundfor a thread of another instance or business, andthread-not-allowedfor any other role that names one. Thesession.launchedbody getsthreadonly when one is set, so an existing run's body is unchanged and a rebind still matches. role.claimfor a thread run writesthread_claims, refusesrun-revokedandthread-already-held, and routes the thread's waiting messages to the new holder.#claimEnd,endLaunch,deliveredandreaduse 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 followsin_reply_toup to the nearest message that has a thread, and refusesthread-instance-mismatchif 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.sendandthread.messages.#threadrefusesthread-not-foundandthread-not-owned, which is REQ-USER-2 with the one user there is today. Agents get none of these verbs. thread.messagespages oldest first, 50 by default.THREAD_PAGE_MAXis 200. That is a page size, so a call returns at most 200 rows and the caller pages on withafter. 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, likebindLaunch, reached through the process opthreadsState. 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 itthreads.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()refusesthread-requiredandthread-not-allowedthe same way the broker does, so a session or--pmlaunch of the assistant fails before a run directory exists.start()callsthreadStore()first and refusesthread-store-refusedon any error. A thread run's sessionDir is the store'ssession/.tick()runs everyTHREAD_TICK_MS, 2 s. It's a poll period. The tests setthreadTickMs. Each tick readsthreadsStateand:- 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 wayclose()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.
- stops a thread session whose thread has nothing pending, nothing unread, and no message or open for
- Two guards stop a loop.
startedForkeeps 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.refusedThreadslogs 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:
- "one holder per thread, through the broker and the trigger": a second claim on a held thread refuses
thread-already-heldand, inserted by hand, hits the trigger. One run can't hold two threads. A released thread takes a new run, andendLaunchreleases. - "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.
- "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. - "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.
- "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.createrefuses two. The chief's reply to the assistant's thread message, sent to the other instance, refusesthread-instance-mismatchand writes no message. Added after the first mutant run, see "Mutants". - "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.
- "a message to a thread with no holder waits, and the next holder gets it", and
thread.openrecords an open with no message. - "the human verbs refuse another owner's thread": REQ-USER-2 on every thread verb, and no agent gets a thread verb.
- "thread.create refuses when the business has no thread instance or more than one" (change 2).
- "projects and threads are named once, and the repo names a business project".
- "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:
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
threadsStateon 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:
- A rebase onto rows 49 and 123 once they land.
- 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. - 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).
- land-work green from the rebase, not from a borrowed hunk.
- 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.