33 KiB
Row 269 build notes (Gizmoduck)
264b, T1 item 7: per-session native store, workspace and single-writer fence. Gitea #1774. Brief:
docs/plans/2026-10-21_t1-sessions.md (blob ff0be0c79), slices in agents/sage/work/queue-264/RULINGS.md
(5f5b51dd6). Reviewer Duckworth, gate owner Sage. Plan gizmoduck-0228, accepted with rulings in
sage-2707. Branch gizmoduck/row269 from refactor aca16404c. Row 253 lands first, and this branch is
rebased onto its tip, under a new name, before the gate.
Claim: rev 3217, op gizmoduck-0230, 2026-10-22 14:29:42Z. That was 21 s after Gander's slot opened at 14:29:21Z (sage-2710). Reported in gizmoduck-0231 and not reverted, since a revert would be a second queue write.
D9 consumer audit, sessions.json (sage-2707 Q3)
Q3 allows session as an optional field with no version bump, if every sessions.json reader at
84f53540d selects by run and not by business, instance or thread. Every reader at 84f53540d:
| Reader | file:line at 84f53540d |
Selects by |
|---|---|---|
readSessions (the only parser) |
packages/seat/src/session.mjs:232-243 | nothing. Refuses any sessionsVersion but 1 (:241) and returns every entry with live |
stopSession (mosaic stop <run>) |
session.mjs:249-250 | run (s.run === run) |
launches list |
packages/cli/src/cli.mjs:526-530 | nothing. Prints every entry, and --json dumps them whole |
host start, leftover |
packages/cli/src/launcher.mjs:235 | business, then recover() works one entry (one run) at a time (:955-972) |
| runtime launch settings | packages/bus/src/runtime.mjs:149 -> packages/business/src/launch-settings.mjs:166 -> packages/business/src/launch.mjs:116-135 (usageCounts) |
business. Counts entries per provider and model |
| Console settings (C1, duckworth-0150) | packages/webui/src/reads.mjs:234 -> the same launchSettings read() -> readSessions |
business, through usageCounts. Any throw becomes launch: {refused: 'not-configured'} |
| stack-release R guard (C1) | scripts/stack-release.sh:2091-2096, its own JSON.parse, no version check | nothing. Takes pid and runnerPid of every entry in sessions and fails sessions-live if one is alive in /proc |
Not a reader: stack-release.sh:2199-2204 checks only that no sessions.json exists. packages/ledger
readSessions (ledger.mjs:146) is a different function that reads seat directories.
launch-settings current() (:129) doesn't call sessions(), so the limits the bus enforces don't
read the file; only the limits verb's read() (:166, runtime.mjs:292) does.
Tests that read the file: packages/cli/tests/launcher.test.mjs (readSessions at :164 through
:1097, each by run, by instance or whole) and packages/cli/tests/host.test.mjs:830 (file absent).
Not a file reader: Registry (session.mjs:192-230). Its list starts empty (:193), and the constructor
writes it over the file (:198). running(business, instance, thread) at :217 is the one key on the
thread, but it reads only the in-memory list this host added (launcher.mjs:529, removed at :553).
Its callers are launcher.mjs:289 (check) and the thread pass. This row changes that key (see Design).
Result: no file reader keys on the thread. Q3's condition holds as worded.
One more finding sends me to v2 anyway. recover() rebinds a leftover run with
{business, role, run, harness, pid, startTime, thread} and no session (launcher.mjs:964).
The broker compares that record's body with the recorded session.launched body, and the body
carries session whenever the launch record had one (broker.mjs:945). A mismatch refuses
launch-record-mismatch (broker.mjs:952). Each new thread run binds with its session (Design), so
after a rollback to 264a, a 264a host that finds a 264b entry logs "not ended, bindLaunch refused:
launch-record-mismatch" (launcher.mjs:968), and the run's claim stays held. The host has
already killed the process (:956-962), but it can't end the run. With v1 and an optional field,
that failure is quiet.
Decision: sessions.json goes to v2 (SESSIONS_VERSION 2). New code reads v1 and v2 and writes v2.
A v2 file keeps the sessions array and every v1 field of each entry, pid and runnerPid
included, so the R guard (stack-release.sh:2094-2095) reads it unchanged (C1).
A v1 entry has no session, and it is treated as the thread's legacy default session (D9). After a
rollback, a 264a host refuses to start with exit 3, "sessions file is malformed"
(session.mjs:241 via launcher.mjs:237), and the README's remedy applies: check which sessions still
run, then move the file aside. Tradeoff: a rollback while runs are live needs a hand step, but it
fails loudly at host start instead of leaving held claims. The rollback note names this refusal.
D9 consumer audit, threadsState and threadIdle (sage-2707 audit addition)
These are trusted launcher APIs, not socket verbs (packages/bus/README.md:288, :294), and they're
reached only through the host op table (packages/bus/src/process.mjs:23-24, :54-55;
runtime.mjs:561-562). At aca16404c:
| Consumer | file:line | What it reads |
|---|---|---|
| launcher thread pass | packages/cli/src/launcher.mjs:596, :611 | every row's thread, holder, pending, unread; threadIdle {business, thread, run} |
| bus end-launch.test | packages/bus/tests/end-launch.test.mjs:193, :198 | whole rows (deepEqual), and not-holder |
| bus thread-migration.test | packages/bus/tests/thread-migration.test.mjs:139 | thread, holder, pending, unread |
| bus threads.test | packages/bus/tests/threads.test.mjs:352, :375-376, :407, :416 | rows keyed by thread |
| cli launcher.test stub | packages/cli/tests/launcher.test.mjs:989 | wraps host.op, counts the op name |
| cli lifecycle.test stub | packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 | passes through, wraps runtime.threadsState |
| cli thread-launch.test stub | packages/cli/tests/thread-launch.test.mjs:90-91, :163, :264-265, :304, :348, :443 | .find(x => x.thread === ...) then holder |
Console and webui: no reader. git grep threadsState over packages/webui, packages/control-board and
scripts has no hit. The webui reads thread.claims (packages/webui/src/serve.mjs, cli.mjs,
public/bus.js, tests shell.test, thread-claims.test, thread-claims-browser.test). This row doesn't change
thread.claims. The webui's session.launched line reads only harness and address
(public/bus.js:151), so a session field in the body changes nothing it shows.
D9 consumer audit, workspace and native store
| Consumer | file:line at aca16404c |
Change in this row |
|---|---|---|
workspaceDir |
packages/seat/src/session.mjs:49 | unchanged, still the non-thread path and the legacy path |
| launcher start, workspace | packages/cli/src/launcher.mjs:397-403 | a thread run uses its session workspace (Q1) |
| launcher instance delete (V5) | launcher.mjs:841-866 | also retires the instance's session workspaces |
threadStore |
session.mjs:59-74, launcher.mjs:388-393 | becomes the legacy branch of sessionStore |
| sandbox binds | packages/seat/src/sandbox.mjs:102-125 | the workspace gets a separate in-sandbox path |
harness transcript() |
packages/plugins/harness/pi/index.mjs:75, claude-code/index.mjs:130 | none: {session, projects} keeps its shape |
in-sandbox readers of session.workspace |
packages/harness/src/runner.mjs:332, :386, :475-476; bundle.mjs:258, :327; gate.mjs:152 | none: they get the in-sandbox path |
| task runner workspaces | scripts/mosaic-task.mjs:351 | none: <dataRoot>/workspaces/<task> is a legacy root the overlap check covers |
| git socket | packages/cli/src/git-socket.mjs:7 | none: it takes bundles and reads no workspace |
Pi and Claude resume, read offline (sage-2707: no pi run while P19 383 holds)
Pinned @earendil-works/pi-coding-agent 0.85.1 (package.json:8), read in node_modules/.../dist:
- adapters/pi/adapter.sh:46-52 passes
--session-dir "$MOSAIC_SESSION_DIR", plus-cwhen that directory isn't empty. - With
-cand an explicit session dir,continueRecentfilters by cwd (core/session-manager.js:1244-1250). It opens the newest*.jsonlwhose header cwd resolves to the process cwd (:397-410,sessionCwdMatches:393-395). With no match, it makes a new session in the same directory (:1251), and nothing fails. - Both adapters
cd "$MOSAIC_WORKSPACE"(pi adapter.sh:34-36, claude adapter.sh:46-47), the runner setsMOSAIC_WORKSPACEfromsession.workspace(runner.mjs:386), and the sandbox binds the workspace at its host path (sandbox.mjs:112,argsOf:128-131). So a legacy transcript's header cwd is<dataRoot>/workspaces/<business>/<instance>. - Opening a file whose header cwd doesn't exist exits 1 in a headless run (main.js:536-548,
core/session-cwd.js:2-15). So
--session <file>is no way around it either.
Result: a legacy default session run from session-workspaces/.../<session> as its cwd would
quietly start a new pi conversation, and its old history wouldn't resume. Claude Code keys its
projects directory on the cwd, and the adapter resumes by id (claude adapter.sh:53-59). I haven't
read the Claude binary, so whether --resume finds an id saved under another cwd is unverified. I
treat it as the same break.
Design
Legacy mapping (D9, M7)
A session is legacy when its row has op='legacy-3l', which the migration gave every pre-3l thread
with id = the thread id (3k-3l.sql:50-51). The rule reads only an immutable row, never the disk.
- A legacy session keeps the thread's existing store,
threads/<b>/<t>/{session,claude-projects}(threadStore, unchanged). - Its workspace is new (Q1),
session-workspaces/<b>/<instance>/<session>/, but inside the sandbox it's bound at the legacy pathworkspaces/<b>/<instance>. The cwd its transcripts recorded is unchanged, so pi-cand Claude--resumefind them. The host's old instance workspace isn't mounted, so it stays as history (Q1, D9). Every in-sandbox reader gets the sandbox path (table above), and host git (ensureCheckouts) gets the host path. - Every other session uses D2's layout for both, and its sandbox path is its host path.
- A thread made by 264a's
thread.createhas a random default id (broker.mjs:5993), so this rule doesn't call it legacy. A 264a launcher ran that thread fromthreads/<b>/<t>/session. A start for a session whose thread has no legacy-3l session, whilethreads/<b>/<t>/sessionexists, refuseslegacy-store-unmapped, so it fails closed and doesn't start a fresh conversation. The re-pin plan lists such threads. If 264a and 264b go live together, there are none.
Broker
threadsState(business): one row per session of every thread, not just the default, ordered by thread seq, then session seq. Each row addssessionandlegacyto the fields it has now.#threadRowsjoinssessionsin place ofsession_defaults(broker.mjs:868-896).threadIdle(record): same keys. The row is the one for the session the run was bound to (#runsentry, broker.mjs:927), so a run is idled against its own session.bindLaunch: no new refusal. It already takessessionand puts it in the body (broker.mjs:945). The broker half of the single-writer fence is the per-session claim that exists now: a second claim on a held session refusesthread-already-held(broker.mjs:4815). Asession-runningrefusal at bind would break two old tests that bind a second unended run to one session and then expect the claim refusal (threads.test.mjs:88-89, sessions.test.mjs:119-120). The launcher's registry check and the workspace lock are the host halves.
Seat
sessionStore(dataRoot, business, thread, session, { legacy }): the legacy branch isthreadStore. Otherwise it'sthreads/<b>/<t>/sessions/<s>/{session,claude-projects}, withthreadStore's checks on every segment and directory (ID_PATTERN, no.., owner, 0700, no symlink), giving the same{dir, session, projects}shape.sessionWorkspace(dataRoot, business, instance, session, { live }): makessession-workspaces/<b>/<i>/<s>0700 with the same checks, then compares realpaths. It refusessession-workspace-overlapwhen the path is inside, or contains,<dataRoot>/workspaces(legacy,.retiredand task workspaces) or any workspace inlive. A segment that fails the lstat checks refusessession-workspace-unsafefirst (C3).- Lock:
lockSessionWorkspace(path, owner)makes<path>.lockwith O_EXCL, next to the workspace and outside every mount. It holds the host's pid and start time, and the run's outer pid and start time once spawned. A lock whose host and run are both gone is stale and is taken over. Anything else refusessession-workspace-busy. start() takes it beforeensureCheckoutsand releases it only after the run'sclose(proven exit) or a failed start. Registry: entries gainsessionandworkspace, and the key becomesrunning(business, instance, thread, session).SESSIONS_VERSIONis 2, andreadSessionsreads 1 and 2.
Launcher
- check(instance, { thread, session }):
instance-runningis per session. Q2 addslegacy-run-live: a start for a thread instance refuses while any registry entry or unrecovered leftover for that instance has nosession. A survivor is still bound and ended host-lost (launcher.mjs:1030-1040), so the broker sees its claim free while it runs. A released claim doesn't prove the run has exited. Only this launcher check holds the instance then, for sessionless survivors and thread starts only (duckworth-0158 N1). recover() waits up to 5 s per leftover for the outer pid and the runner (N2, see "recover() change, for a design read"). Residual (duckworth-0159 N3): a survivor with asessioncounts nowhere, because :309 counts only sessionless entries. The same session could start again while the old runner lives. The lock doesn't catch it either: it records the outer pid as runPid (launcher.mjs:574), so with the outer pid gone it reads stale and is taken over. The base has the same exposure. In practice the runner dies with its pid namespace when the outer bwrap is killed. - start(): the store and workspace come from the session. The lock covers checkouts and spawn.
bindLaunchgetssession, and the entry records it. - threadPass(): one pass per session row.
startedForandrefusedThreadskey on the session. Thethread-idle,refusedandendrecords addsession. - recover(): passes the entry's
sessionwhen present. - remove(): moves
session-workspaces/<b>/<instance>tosession-workspaces/<b>/.retired/<instance>-<transaction>after the legacy move, and recordsworkspace-retiredwith that path. - Admission (RULINGS Q5, D8): a named session starts only through threadPass, then check(), then
start(), so it passes through every
admission()call start() makes now (launcher.mjs:382-524). There is no second start path.
Sandbox
binds() takes workspaceDest (default workspace), the in-sandbox path for the rw workspace
bind. The store binds keep their shape: only the selected session's two directories.
Tests
T1 run half, with the fake harness adapter from thread-launch.test (no pi or Claude binary):
two sessions of one thread run at once, each with its own sentinel history, store and workspace;
stopping one and restarting it resumes only that one. The legacy session resumes from the old
store at the old in-sandbox cwd. A non-thread instance gets thread-not-allowed.
T2: a second start on a live session's workspace refuses session-workspace-busy before any
checkout; a hand-made live lock from another host refuses; a symlinked or nested session root
refuses session-workspace-unsafe; a workspace root symlinked into the session path refuses
session-workspace-overlap; a store segment symlink refuses thread-store-refused. Under a halt, a
named session's start refuses processing-halted and spawns nothing. A generation change after
check() refuses processing-generation-changed and spawns nothing (M6).
N1 (DEFERRED.md:474-480): refusedOnce in packages/bus/tests/sessions.test.mjs pins actor_run.
Old assertions that change (C4, duckworth-0150, mos-1285)
Every pre-existing test that reads threadsState at aca16404c, checked against one row per session:
- packages/bus/tests/end-launch.test.mjs:193-195 deepEquals the whole row. This one changes. Mos
allowed it in mos-1285 as an exact update: still a whole deepEqual on threadsState('demo'), still
exactly one th-1 row, every old key and value the same, and two new keys with literal values,
session: 'th-1'(the fixture's legacy default, :14-15) andlegacy: true(oplegacy-3l). The commit message names mos-1285 and the old row shape. - packages/bus/tests/threads.test.mjs:352, :375-376: fromEntries keyed by thread. Neither test, and
neither fixture (
twoThreads), makes a second session (nosession.createin the file), so each thread still has one row. Unchanged. - packages/bus/tests/thread-migration.test.mjs:139: maps four fields of a one-session thread. Unchanged.
- packages/cli/tests/thread-launch.test.mjs:163, :304, :348, :443:
findby thread. One session per thread in every fixture. Unchanged. The default session also sorts first, by session seq. - packages/cli/tests/lifecycle.test.mjs:90-91, :749-752 and thread-launch.test.mjs:90-91, :264-265 pass threadsState through. Unchanged.
- packages/cli/tests/thread-launch.test.mjs:182 (found in the code round, after mos-1285). The test
makes threads with
thread.create(:171-172), so A's default session has a random id and opdefault(broker.mjs:5992-6009), notlegacy-3l. The brief puts every new store in D2's layout (t1-sessions.md:100, :294), so A's store moves tothreads/acme/<A>/sessions/<A's session>. The proposed exact update changes only thestorepath on :182, taking the session id fromthreadsState(the test already reads it at :162). Lines :183-185 stay as they are. One added line asserts thatthreads/acme/<A>/sessiondoesn't exist. This goes to Mos through Sage, and no code commit edits the file until Mos rules. - The same file's :375 (
../evilrefusesthread-store-refused) and :381, :384 (nothreads, noworkspaces) stay unchanged: start() checks the store before it makes the session workspace.
Any other old assertion the code changes goes to Mos through Sage before the commit. After the first run I survey every touched file whole for one.
Mutants
Each kill counts only as an assertion failure in a test that runs that path, never a throw or another fence (sage-2707, C3 duckworth-0128).
| Id | Change | Killed by |
|---|---|---|
| M1 | sessionWorkspace drops the session segment: a thread's sessions share one checkout |
T2 same-thread fence: session-workspace-busy, no second spawn |
| M2 | the session segment becomes the session name: two threads' default sessions share one checkout |
T2 cross-thread fence, same assertion |
| M3 | the lock ignores a live holder | T2 hand-made lock: session-workspace-busy |
| M4 | the realpath overlap check removed | T2 nesting: session-workspace-unsafe |
| M5 | the launcher passes the thread's default store for every session | T1: the second session's sentinel history is its own |
| M6 | start() calls a no-op in place of every admission(admissionGeneration) (launcher.mjs:382, :396, :407, :420, :495, :499, :520, :524) for a non-legacy session |
the generation test (below): assert.rejects with code processing-generation-changed, then assert.equal spawn count 0 |
| M7 | the legacy session maps to sessions/<s>/ |
the D9 test: the legacy run resumes the sentinel in threads/<b>/<t>/session |
| M8 | a non-default session binds its workspace at the legacy workspaces/<b>/<instance> in-sandbox path |
T1: assert.equal that a named session's in-sandbox workspace is its session-workspaces/<b>/<instance>/<s> path, read from the fake adapter's recorded cwd (sage-2717) |
| M9 | #threadRows back to the default session only |
T1: the named session's message starts its run |
| M10 | the legacy-run-live refusal removed from check() |
the legacy-run-live test: outcome equals [[S, "legacy-run-live"]] |
| M11 | recover() keeps a survivor only when the outer pid outlived the kill (the runner ignored) | the legacy-run-live test: the survivor log assert.ok (session-launch.test.mjs:309); the host doesn't see the live runner, so the survivor line is never logged (run 17:49Z 10-22, duckworth-0181) |
| M12 | check() reads every survivor, live or not | the legacy-run-live test: after the runner exits, settle gives one launch |
M6 and the halt fences (C2, duckworth-0150). A plain halt never reaches start(). tick() admits first (:579) and runs no thread pass while halted (:583), and check() admits at :282 and :376. The kill test lands the change between check() returning and the spawn:
- The fake host's
credentialStatusop (start() calls it at :395, after check() returned and after :382) records that it ran, then halts and resumes processing throughtransition. That leaveshaltedfalse and a new generation (state.mjs:115-121). - The tick and check fences see no halt, and start()'s second check() (:419) takes its own
generation, so it passes too. withProcessing at :406 has no parent context (the launcher never
opens one) and checks only
halted, so it passes. The one fence left is start()'s ownadmission(admissionGeneration), which refusesprocessing-generation-changedat :396. - The test asserts the hook ran (check passed), then the refusal code, then no spawn. M6 removes all eight calls because removing only :382 leaves :396 to refuse. The T2 halt test stays as written in Tests, but it doesn't kill M6 and isn't counted for it.
M4 and the segment checks (C3, duckworth-0150). The lstat check on each segment refuses a symlinked
session root before the realpath compare runs, so the compare gets its own code,
session-workspace-overlap, and the segment check keeps session-workspace-unsafe. M4's kill input
passes every segment check: <dataRoot>/workspaces/<b> is a symlink to
session-workspaces/<b>/<i>/<s>. Each segment of the session path is a real directory, and only the
realpath compare finds that the session path contains the legacy workspace root. The test asserts
assert.rejects with code session-workspace-overlap and no spawn. With M4 the start spawns, so
the rejection assertion fails. The segment test keeps asserting session-workspace-unsafe for a
symlinked session root, so it tells the two codes apart.
Duckworth's check for the early look (sage-2717): a non-default session never gets the legacy
workspaces/<b>/<instance> bind path. Only a session with op legacy-3l takes workspaceDest
set to the legacy path. M8 is that mutant.
Code round corrections (local, not yet run)
Written before the first run, which waits for Sage's CLEAR.
- Tests live in two new files: packages/cli/tests/session-launch.test.mjs (T1, D9, T2) with a new stub adapter, fixtures/session-adapter.mjs (thread-adapter.mjs's turn plus the run's cwd and its entries; thread-adapter.mjs is unchanged), and packages/seat/tests/session-workspace.test.mjs (sessionStore, sessionWorkspace, the lock, sessions.json v2). The new launch tests copy thread-launch.test.mjs's host helpers rather than change them, so no other old test file changes.
- M1 and M2 kill in T1, not in a busy test. A test that expects
session-workspace-busystill passes when sessions share a path. T1 starts thread A's default and named sessions and thread B's default at once and asserts no refusal, three live entries and three distinct cwds. With M1 (A's two sessions) or M2 (the twodefaultsessions) the second start refuses busy, and thedeepEqual(refused, [])assertion fails. - Every wait a mutant could stall returns what it last saw (
settle,outcome), so a kill is the assertion after it, never a timeout. That covers M3, M4, M6 and M9, where the mutant's run starts or never starts. - The M6 test also carries the halt fence. start() admits right after credentialStatus, before it
makes the session workspace, so the first refusal leaves no workspace. On the retry the recheck's
mayLaunch op halts after the lock is taken. The recheck refuses
processing-halted, and the test asserts that the lock is gone (startLocked's throw releases it). - The lock's release() runs once. A second call would unlink a lock another host has written since.
legacy-run-livenow has a test (Duckworth's early warning in duckworth-0156). Before, recover() kept a survivor only when a bwrap pid outlived SIGKILL, which a test can't make. It also ended a leftover whose runner still ran once the outer pid was gone, so the refusal never saw that run. Now recover() waits up to 5 s on the outer pid and the runner. It keeps any leftover where either still runs, and logsrun <run> (<instance>) from an earlier host still runs. check() reads only survivors that still run, so a start goes ahead once the run has exited. The test writes a sessionless leftover whose runner is a sleep child and whose outer pid is gone. It asserts the refusal, no launch, no session workspace, and then one launch after the child is killed. M10-M12 cover it. The registry and pre-recover leftover terms stay. A launching host can't reach them for a thread instance (check() refusessession-requiredfirst, and listen() runs recover() first), but they cost nothing.
recover() change, for a design read (sage-2750)
Sage asked for this because recover() predates the row. Lines are launcher.mjs at 3d29876df plus
the log-line edit after it, against aca16404c:947-976.
-
What stays the same: every leftover is still bound and ended as host-lost (:1030-1040, base :963-973). The survivor push (:1026-1029) comes before the bind, and the loop carries on past it, so a kept leftover isn't left unended. Its bus run ends and its role is released, as in the base. The kill is unchanged too: SIGKILL only to a live outer pid whose cmdline is bwrap (:1018-1021, base :956-959).
-
What changes:
- The wait after the kill runs while the outer pid or the runner still runs (:1023). In the base it waited on the outer pid only (base :961).
- A leftover where either still runs after that goes on
survivorsand is logged (:1026-1029). - check() counts a survivor only while it still runs (:309,
liveRunat :255).
-
a. Nothing in this row kills a kept leftover's runner. The base didn't either: it ended the run and let the runner go on, unseen. The run is still ended (:1038), so the runner holds no role. Its capability died with the old broker (:1004-1008). The only thing a survivor does is refuse
legacy-run-livefor sessions of its instance while it lives, and each refusal is arefusedlaunch record. For an operator, the log line names the run, the outer pid and the runner pid (:1028). Killing that pid clears it. check() re-reads liveness on each pass (:309), so the next tick starts the session with no host restart. If bindLaunch refuses (:1032-1036), the run stays unended and logged, as in the base (base :965-969). The survivor entry doesn't change that. -
b. The 5 s wait only applies inside the kill branch (:1018-1024), so only to a leftover whose outer bwrap was still alive. A leftover with nothing running doesn't wait, and nor does one whose outer pid is gone: neither enters the branch. The wait ends as soon as both processes are gone. The base already waited up to 5 s in the same branch. It can now last longer only if the runner outlives its outer pid. With
--unshare-pidand--die-with-parentthe runner dies when the namespace goes. -
c. Old tests that run recover() with a launching host and a leftover:
- launcher.test.mjs:885-943, the "host that died hard" pair. It asserts the outer pid and the runner are gone right after setup (:917-918), the host-lost log (:919), one session.ended (:920-922), one end record (:924), an empty sessions.json (:925), and then "already ended" for a leftover whose processes are dead (:937-941). None of these expected values change. Waiting on the runner makes :918 hold sooner, not later. The new log line can appear only if a process outlives 5 s, which would already fail :917-918. The dead-process third host takes no wait and logs no survivor.
- launcher.test.mjs:950-971 (
--no-launch) returns before the loop (:1012-1015). It's unchanged. - stack-release.test.mjs:1928 writes
{pid: process.pid}with no start times.liveRunis false for it, and that test reads it through bus status, not recover(). - verbs.test.mjs:213 and the seat tests build a Registry with no launcher, so they aren't affected.
No expected value in an old test changes, so nothing goes to Mos under mos-1272 for this. If a run shows otherwise, I stop and send it before any change.
-
Fallback, if Duckworth or Sage doesn't accept this: revert recover() and check() to the base behaviour, keeping only the sessionless check over the registry, the pre-recover leftovers and bwrap survivors. Drop the legacy-run-live test and M10-M12, and name the untestable path here.
-
N1 (refusedOnce pins
actor_run) changes a pre-existing test. Mos allowed it in mos-1292 (copy mos-1294) on mos-1285's terms, and it's committed asa81b3446e, with a closing line on the DEFERRED.md entry.
Rollback
264b writes sessions.json v2. A 264a host given that file refuses to start, exit 3, "sessions file is malformed" (session.mjs:241 via launcher.mjs:235-237). Session workspaces and per-session stores stay on disk, and a 264a host never reads them.
What else refuses on a 264a stack while a v2 file is in place (C1, duckworth-0150). All of them go through the 264a readSessions, which throws SeatError "sessions file is malformed" (session.mjs:241):
mosaic launches listandmosaic stop <run>: exit 2 with that message (cli.mjs:526, :465).- the bus
limitsverb:settings.read()throws (runtime.mjs:149, :292, launch-settings.mjs:166), so the verb fails with that message. The limits the bus enforces still work, sincecurrent()doesn't read the file. I haven't traced the code a bus caller sees for that throw. - the Console settings view: launch shows
not-configured(reads.mjs:234). - the R guard keeps working (above). All of these read the file on each call, so step 3 below clears them without a restart.
Console restart: the shape of what the Console reads doesn't change. usageCounts still counts
entries per provider and model, and reads.mjs keeps its fields. What changes is the file, and a
Console still on 264a code shows launch not-configured from the first v2 write until a human
restarts it on a 264b copy. The re-pin that carries this row needs its Console restart (Jason or
Mos), done right after the host's, as it does for a read-shape change. Step 3 covers the reverse
case after a rollback with no restart.
Seeing live sessions without launches list (C1): read the file directly the way the R guard does.
For each entry in sessions, a session is live if /proc/<pid>/stat or /proc/<runnerPid>/stat
exists and its state isn't Z (stack-release.sh:2080, :2095). For a stronger check, compare
runnerStartTime with field 22 of /proc/<runnerPid>/stat, which is what readSessions' live
does through startTimeOf.
How a rollback host gets past exit 3 (sage-2717), run on NEW before the rollback activate:
- Stop the bus host on NEW. Its stop SIGKILLs what is left and ends those launches as
killed(packages/cli/README.md:470). - Read
sessions.jsonwith NEW's readSessions (mosaic launches list, cli.mjs:526-530). Go on only if it shows no live entry: every listed runner is gone (pid and start time no longer match). If NEW's CLI is already gone, use the /proc read above. - Move
sessions.jsonaside under a dated name, never delete it (packages/cli/README.md:478). - Then roll back. The 264a host starts with no leftover file and nothing to recover.
A live entry at step 2 stops the rollback until that run ends or
mosaic stop <run>ends it.
Which re-pin plan step carries it: no re-pin plan names 264b yet. The 84f53540d re-pin carries 264a
only. The first re-pin whose NEW includes this row puts steps 1-4 in its "Stop conditions and
rollback" section, before rollback --plan, the section the 10-24 plan uses
(agents/sage/work/window-plan/repin-1024-draft1.md:144-148). I'll name it to whoever drafts that
plan when the row lands.
Run limits (sage-2707)
Each node --test file and suite runs under timeout -k 10 LIMIT, and each scripts/test-*.sh under at
least 3x its longest recorded run. Records are from agents/gizmoduck/work/queue-264/BUILD.md "Run
limits" unless named.
| Run | Longest recorded | LIMIT |
|---|---|---|
| stack-release.test alone | 575 s (264a r12 survey) | 1800 s |
| each other touched file alone | 15 s (thread-launch.test); the rest have none | 300 s |
| bus suite | 23 s | 900 s |
| cli suite | 591 s (row 281 gate) | 1800 s |
| webui suite | 57 s | 600 s |
| seat suite | none recorded | 600 s |
| harness suite | none recorded | 600 s |
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
| each mutant probe | 1 s wall (264a M1-M4) | 300 s |