Files

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 -c when that directory isn't empty.
  • With -c and an explicit session dir, continueRecent filters by cwd (core/session-manager.js:1244-1250). It opens the newest *.jsonl whose 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 sets MOSAIC_WORKSPACE from session.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 path workspaces/<b>/<instance>. The cwd its transcripts recorded is unchanged, so pi -c and Claude --resume find 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.create has a random default id (broker.mjs:5993), so this rule doesn't call it legacy. A 264a launcher ran that thread from threads/<b>/<t>/session. A start for a session whose thread has no legacy-3l session, while threads/<b>/<t>/session exists, refuses legacy-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 adds session and legacy to the fields it has now. #threadRows joins sessions in place of session_defaults (broker.mjs:868-896).
  • threadIdle(record): same keys. The row is the one for the session the run was bound to (#runs entry, broker.mjs:927), so a run is idled against its own session.
  • bindLaunch: no new refusal. It already takes session and 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 refuses thread-already-held (broker.mjs:4815). A session-running refusal 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 is threadStore. Otherwise it's threads/<b>/<t>/sessions/<s>/{session,claude-projects}, with threadStore'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 }): makes session-workspaces/<b>/<i>/<s> 0700 with the same checks, then compares realpaths. It refuses session-workspace-overlap when the path is inside, or contains, <dataRoot>/workspaces (legacy, .retired and task workspaces) or any workspace in live. A segment that fails the lstat checks refuses session-workspace-unsafe first (C3).
  • Lock: lockSessionWorkspace(path, owner) makes <path>.lock with 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 refuses session-workspace-busy. start() takes it before ensureCheckouts and releases it only after the run's close (proven exit) or a failed start.
  • Registry: entries gain session and workspace, and the key becomes running(business, instance, thread, session). SESSIONS_VERSION is 2, and readSessions reads 1 and 2.

Launcher

  • check(instance, { thread, session }): instance-running is per session. Q2 adds legacy-run-live: a start for a thread instance refuses while any registry entry or unrecovered leftover for that instance has no session. 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 a session counts 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. bindLaunch gets session, and the entry records it.
  • threadPass(): one pass per session row. startedFor and refusedThreads key on the session. The thread-idle, refused and end records add session.
  • recover(): passes the entry's session when present.
  • remove(): moves session-workspaces/<b>/<instance> to session-workspaces/<b>/.retired/<instance>-<transaction> after the legacy move, and records workspace-retired with 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) and legacy: true (op legacy-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 (no session.create in 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: find by 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 op default (broker.mjs:5992-6009), not legacy-3l. The brief puts every new store in D2's layout (t1-sessions.md:100, :294), so A's store moves to threads/acme/<A>/sessions/<A's session>. The proposed exact update changes only the store path on :182, taking the session id from threadsState (the test already reads it at :162). Lines :183-185 stay as they are. One added line asserts that threads/acme/<A>/session doesn't exist. This goes to Mos through Sage, and no code commit edits the file until Mos rules.
  • The same file's :375 (../evil refuses thread-store-refused) and :381, :384 (no threads, no workspaces) 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 credentialStatus op (start() calls it at :395, after check() returned and after :382) records that it ran, then halts and resumes processing through transition. That leaves halted false 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 own admission(admissionGeneration), which refuses processing-generation-changed at :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-busy still 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 two default sessions) the second start refuses busy, and the deepEqual(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-live now 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 logs run <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() refuses session-required first, 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:

    1. 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).
    2. A leftover where either still runs after that goes on survivors and is logged (:1026-1029).
    3. check() counts a survivor only while it still runs (:309, liveRun at :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-live for sessions of its instance while it lives, and each refusal is a refused launch 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-pid and --die-with-parent the 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. liveRun is 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 as a81b3446e, 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 list and mosaic stop <run>: exit 2 with that message (cli.mjs:526, :465).
  • the bus limits verb: 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, since current() 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:

  1. Stop the bus host on NEW. Its stop SIGKILLs what is left and ends those launches as killed (packages/cli/README.md:470).
  2. Read sessions.json with 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.
  3. Move sessions.json aside under a dated name, never delete it (packages/cli/README.md:478).
  4. 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