row 269 (264b, #1774): design notes. D9 audit of every sessions.json reader at 84f53540d and of threadsState's consumers; sessions.json goes to v2 because recover() rebinds without session; pi 0.85.1 resumes by cwd, so the legacy session keeps its in-sandbox path; mutants M1-M8 and run limits (sage-2707)
This commit is contained in:
1 parent
aca16404cf
commit
875b7eeebf
1 file changed
+236
@@ -0,0 +1,236 @@
|
||||
# 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 |
|
||||
|
||||
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 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`: a new run on a session that another unended run in this broker is bound to refuses
|
||||
`session-running`. That's the broker half of the fence for two launcher processes. A rebind of an
|
||||
existing run (`recover()`) skips the check, as it skips the harness check now.
|
||||
|
||||
### 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-unsafe` when the path is inside, or contains, `<dataRoot>/workspaces` (legacy,
|
||||
`.retired` and task workspaces) or any workspace in `live`.
|
||||
- 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`.
|
||||
- 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 store segment symlink refuses `thread-store-refused`. Under
|
||||
a halt, a named session's start refuses `processing-halted` and spawns nothing.
|
||||
|
||||
N1 (DEFERRED.md:474-480): `refusedOnce` in packages/bus/tests/sessions.test.mjs pins `actor_run`.
|
||||
|
||||
## 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() skips `admission()` for a non-legacy session | the halt test: `processing-halted`, no spawn |
|
||||
| M7 | the legacy session maps to `sessions/<s>/` | the D9 test: the legacy run resumes the sentinel in `threads/<b>/<t>/session` |
|
||||
| M8 | `#threadRows` back to the default session only | T1: the named session's message starts its run |
|
||||
|
||||
## 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). Check which sessions still run, then move
|
||||
the file aside (packages/cli/README.md:478). Session workspaces and per-session stores stay on disk,
|
||||
and a 264a host never reads them.
|
||||
|
||||
## 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 |
|
||||
Reference in new issue
Block a user