control board: stale registrations and a launch-test leak (#1504)

Two defects in 69f99323, reported by the professor session and verified.

The darkwing launch test's flock-contention spawn ran without the fixture
config, so launch.sh re-entered scripts/mosaic against the real data root
and wrote fixture records for darkwing, dewey and filbert there. That
spawn now names the fixture config, and both launch test files set
MOSAIC_CONFIG to a nonexistent path and clear MOSAIC_LAUNCH_REGISTERED
process-wide, so a spawn that forgets fails instead of polluting.

A registration is written before the launch script's own checks, so a
refused launch left a record with a dead pid that the board honoured. The
scanner now probes the recorded pid (pidAlive, signal 0); a gone pid makes
the record stale: still on the Registered line with alive false, derived
task, project and workspace win, index gains registrationStale, CLI
summary gains a stale count.

Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15,
launch scripts 5/5. Sonnet review APPROVED.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 11:04:49 -05:00
co-authored by Claude Fable 5.1
parent 69f99323c7
commit 17153fe140
14 changed files with 166 additions and 18 deletions
+20
View File
@@ -2102,3 +2102,23 @@ on the way: Jason's own launch refused on the Pi pin because
`node_modules` still held 0.84.4 after commit 557aba0f moved the pin to `node_modules` still held 0.84.4 after commit 557aba0f moved the pin to
0.85.1; `npm ci` fixed it and the refusal message now names both versions. 0.85.1; `npm ci` fixed it and the refusal message now names both versions.
The pin is canon and stays unless Jason decides otherwise. The pin is canon and stays unless Jason decides otherwise.
## 2026-09-12 — Seat registration follow-up: test leak and stale records (#1504)
Before: the darkwing launch test's flock-contention spawn ran without the
fixture config, so `launch.sh` re-entered `scripts/mosaic` against the real
data root and wrote fixture records for darkwing, dewey and filbert there
(dead pids, `/tmp` workspaces), overwriting Jason's live darkwing record.
Separately, a launch the script itself refused (Pi pin) left a record with
a dead pid that the board showed as registered. Both reported by the
professor session, verified against the commit and the data root.
After: the spawn names the fixture config; both launch test files set
`MOSAIC_CONFIG` to a nonexistent path and clear `MOSAIC_LAUNCH_REGISTERED`
process-wide so a forgotten env fails instead of polluting. The scanner
probes the recorded pid (`pidAlive`, signal 0, EPERM counts as alive); a
gone pid marks the record stale: kept on the Registered line with
`registered.alive` false, derived values win, `registrationStale` on the
index, stale count in the CLI summary. Fleet launchers marked not planned
per Jason. Tests: board 90/90, seat 15/15, launch scripts 5/5, real data
root untouched by the run. The three fixture records were deleted by hand.
+1
View File
@@ -221,3 +221,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded). - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded).
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, Gate A fix (#1503), asked by the professor session on Jason's behalf: per-row Task (first user message), Active project (nearest git checkout above the workspace) and Workspace (live tmux pane path, else session cwd), "unknown" when not derivable; missing launcher signals logged on the plan page. Fixture test per field (79/79); live check on a scratch server; sonnet review APPROVED (no findings). - 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, Gate A fix (#1503), asked by the professor session on Jason's behalf: per-row Task (first user message), Active project (nearest git checkout above the workspace) and Workspace (live tmux pane path, else session cwd), "unknown" when not derivable; missing launcher signals logged on the plan page. Fixture test per field (79/79); live check on a scratch server; sonnet review APPROVED (no findings).
- 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise. - 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise.
- 2026-09-12 — darkwing (Claude Code, coordinator) — #1504 follow-up on two defects the professor session reported and this session verified: the darkwing launch test leaked fixture registrations into the real data root (flock spawn without the fixture config; fixed and guarded so a leak fails), and a refused launch left a dead-pid record the board honoured (scanner now probes the pid; stale records are shown but do not override). Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15, launch scripts 5/5. Committed and pushed to refactor.
+35 -1
View File
@@ -250,7 +250,10 @@ package `packages/seat` and wrapper `scripts/mosaic`:
plain `agents/darkwing/launch.sh` registers too. rocko registers but is plain `agents/darkwing/launch.sh` registers too. rocko registers but is
still not on the board: it has no pi session log to scan. Gap, not a bug. still not on the board: it has no pi session log to scan. Gap, not a bug.
- Fleet launchers under `~/.mosaic` are not edited from this repository. - Fleet launchers under `~/.mosaic` are not edited from this repository.
One-line change for orch-01 to apply in Not planned (Jason's ruling, 2026-09-12, relayed by the professor
session): fleet seats stay on the old launchers; only `agents/` seats
use `scripts/mosaic`. The one-liner below is kept for the record only.
One-line change that would do it, in
`~/.mosaic/fleet/bin/launch-seat.sh`, after `SEAT_DIR=` is set (the `~/.mosaic/fleet/bin/launch-seat.sh`, after `SEAT_DIR=` is set (the
per-seat `launch.sh` files are shims and need nothing): per-seat `launch.sh` files are shims and need nothing):
`[[ -n "${MOSAIC_LAUNCH_REGISTERED:-}" || "$CHECK" -eq 1 ]] || exec /mnt/storage/src/mosaic-stack/scripts/mosaic launch --harness pi "$SEAT_DIR" -- "$@"` `[[ -n "${MOSAIC_LAUNCH_REGISTERED:-}" || "$CHECK" -eq 1 ]] || exec /mnt/storage/src/mosaic-stack/scripts/mosaic launch --harness pi "$SEAT_DIR" -- "$@"`
@@ -290,3 +293,34 @@ package.json (0.85.1)". Commit 557aba0f (#1499) moved the pin without an
already 0.85.1, which hid it). Fixed by `npm ci`; the refusal now names already 0.85.1, which hid it). Fixed by `npm ci`; the refusal now names
both versions. Jason questioned the pin itself; it is canon (AGENTS.md both versions. Jason questioned the pin itself; it is canon (AGENTS.md
"Version pin") and stays unless he decides otherwise. "Version pin") and stays unless he decides otherwise.
**2026-09-12 — Two defects in 69f99323, reported by the professor session
and verified here.** (1) The darkwing launch test's flock-contention spawn
inherited the plain environment, so the launch script re-entered
`scripts/mosaic` with the default config and wrote fixture records for
darkwing, dewey and filbert into the real data root (dead pids, workspaces
under `/tmp`), overwriting the record of Jason's live darkwing. Fix: that
spawn names the fixture config, and both launch test files now set
`MOSAIC_CONFIG` to a nonexistent path and clear `MOSAIC_LAUNCH_REGISTERED`
for the whole process, so any spawn that forgets fails instead of
polluting. The three fixture records were deleted by hand; Jason's next
darkwing launch writes the real one. (2) A registration is written before
the launch script's own checks, so a refused launch (Jason's Pi pin refusal)
left a record with a dead pid that the board showed as registered. The
scanner now probes the recorded pid on every pass (`pidAlive`, signal 0);
a gone pid makes the record stale: still on the Registered line, marked
stale with `registered.alive` false, but derived task, project and
workspace win. Index gains `registrationStale`; the CLI summary a stale
count. Tests: board 90/90 (was 89), seat 15/15, launch scripts 5/5; the
launch tests run against the real data root untouched (checked by
timestamp). Also from Jason: fleet seats stay on the old launchers; the
orch-01 one-liner above is marked not planned.
Observation from Jason, same day: after relaunching darkwing, the row's
"Last message" was still from the earlier session. Cause, not a scanner
bug: Pi writes the session log on the first message, and `--continue`
resumes the newest existing log, so until the relaunched seat sends or
receives a message there is nothing newer to read. The registration's
`startedAt` and pid are the only trace of the relaunch, which is what the
Registered line is for. A later refinement could say "relaunched at X, no
messages since" when `startedAt` is newer than the last activity.
+3 -2
View File
@@ -28,8 +28,9 @@ Jason's go on the professor session's brief opened #1504: seat registration.
leaves one record at `<dataRoot>/seats/<layout>/<seat>/registration.json` that the leaves one record at `<dataRoot>/seats/<layout>/<seat>/registration.json` that the
board reads instead of guessing; `scripts/mosaic seat task <seat> <text>` board reads instead of guessing; `scripts/mosaic seat task <seat> <text>`
changes the task. The four repository launch scripts register themselves. changes the task. The four repository launch scripts register themselves.
Package: `packages/seat`. Fleet launchers under `~/.mosaic` are not edited Package: `packages/seat`. Fleet seats stay on their own launchers (Jason's
from this repository; the one-line change for them is in the plan page. ruling, 2026-09-12); only `agents/` seats register. A record whose pid is
gone is stale and does not override the derived values.
Next action: step 3 of #1503 continues — daily use with #1504 landed. Jason Next action: step 3 of #1503 continues — daily use with #1504 landed. Jason
launches his seats through the new path (or the launch scripts, which now launches his seats through the new path (or the launch scripts, which now
+8 -1
View File
@@ -82,7 +82,7 @@ node --test packages/control-board/tests/
``` ```
<dataRoot>/board/ <dataRoot>/board/
index.json # summary: counts, waiting-on-you, seen, registered, registrationErrors, all records index.json # summary: counts, waiting-on-you, seen, registered, registrationStale, registrationErrors, all records
seen.json # Jason's "seen" marks (see below) seen.json # Jason's "seen" marks (see below)
sessions/ sessions/
<project>/ <project>/
@@ -126,6 +126,13 @@ in the record leaves the derived value in place. Rows with no registration
are exactly as before. The board only reads `seats/`; `mosaic launch` and are exactly as before. The board only reads `seats/`; `mosaic launch` and
`mosaic seat task` are the only writers. A malformed record is listed in `mosaic seat task` are the only writers. A malformed record is listed in
`registrationErrors` on the index (and on stderr for `scan`) and skipped. `registrationErrors` on the index (and on stderr for `scan`) and skipped.
A record is written before the launch script's own checks run, so a refused
launch leaves a record whose pid is gone. The scan probes the recorded pid
on every pass; when it is gone the record is stale: the row keeps the
Registered line (marked stale, `registered.alive` false) but the derived
task, project and workspace win and the sources say so. Stale rows are
listed under `registrationStale` on the index. A record with no pid is
never stale.
Marked rows are listed under a collapsed "Seen (N)" section on the page, Marked rows are listed under a collapsed "Seen (N)" section on the page,
each with an "Unsee" button, so nothing marked is ever out of reach. Each each with an "Unsee" button, so nothing marked is ever out of reach. Each
+1 -1
View File
@@ -59,7 +59,7 @@ async function main() {
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`); process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
} }
} }
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered)\n`); process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`);
} }
main().catch((err) => { main().catch((err) => {
+1
View File
@@ -197,6 +197,7 @@
if (reg.pid) parts.push("pid " + esc(reg.pid)); if (reg.pid) parts.push("pid " + esc(reg.pid));
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : "")); if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
if (reg.layout) parts.push(esc(reg.layout) + " layout"); if (reg.layout) parts.push(esc(reg.layout) + " layout");
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
return parts.join("; "); return parts.join("; ");
} }
+25 -6
View File
@@ -276,6 +276,18 @@ export function matchRegistration(spec, registrations) {
return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null; return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null;
} }
// True when a pid is running (a signal-0 probe; EPERM still means running),
// false when it is gone, null when there is no pid to check.
export function pidAlive(pid) {
if (!(Number.isInteger(pid) && pid > 0)) return null;
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err.code === "EPERM";
}
}
// One agent -> one status record. // One agent -> one status record.
// //
// Three fields answer "what is this seat doing, and where" (Gate A ask, // Three fields answer "what is this seat doing, and where" (Gate A ask,
@@ -289,7 +301,11 @@ export function matchRegistration(spec, registrations) {
// else the session log's cwd. // else the session log's cwd.
// activeProject registration.project, else the basename of the nearest git // activeProject registration.project, else the basename of the nearest git
// repo root above the workspace. // repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null } = {}) { // A registration is written before the launch script's own checks run, so a
// refused launch leaves a record whose pid is gone. Such a record is stale:
// it is still reported under `registered` (with alive false) but the derived
// values win, because the record describes a launch that is not running.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) {
const live = liveness(isAlive(spec.tmux)); const live = liveness(isAlive(spec.tmux));
const alive = live.alive; const alive = live.alive;
const file = findNewestSession(spec.sessionsDir); const file = findNewestSession(spec.sessionsDir);
@@ -301,7 +317,9 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const needsYou = state === "waiting" || state === "error"; const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity; const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null; const cwd = session?.cwd ?? null;
const reg = registration && typeof registration === "object" ? registration : null; const record = registration && typeof registration === "object" ? registration : null;
const registeredAlive = record ? isPidAlive(record.pid) : null;
const reg = record && registeredAlive !== false ? record : null;
const derivedWorkspace = live.workspace ?? cwd; const derivedWorkspace = live.workspace ?? cwd;
const workspace = reg?.workspace ?? derivedWorkspace; const workspace = reg?.workspace ?? derivedWorkspace;
const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null; const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null;
@@ -329,8 +347,8 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
workspaceSource, workspaceSource,
activeProject, activeProject,
activeProjectSource, activeProjectSource,
registered: reg registered: record
? { startedAt: reg.startedAt, updatedAt: reg.updatedAt, harness: reg.harness, pid: reg.pid, tmux: reg.tmux, layout: reg.layout, launchScript: reg.launchScript } ? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript }
: null, : null,
lastActivity, lastActivity,
ageSeconds, ageSeconds,
@@ -369,12 +387,12 @@ function writeAtomic(path, data) {
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json. // Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
// seatsDir (optional): where `mosaic launch` registrations live; read only. // seatsDir (optional): where `mosaic launch` registrations live; read only.
export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) { export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) {
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path"); if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null"); if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
const seen = loadSeen(boardDir); const seen = loadSeen(boardDir);
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir); const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations) })); const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
for (const rec of records) { for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project); const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 }); mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -387,6 +405,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) {
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey), waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
seen: records.filter((r) => r.seen).map(seenKey), seen: records.filter((r) => r.seen).map(seenKey),
registered: records.filter((r) => r.registered).map(seenKey), registered: records.filter((r) => r.registered).map(seenKey),
registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey),
registrationErrors, registrationErrors,
sessions: records, sessions: records,
}; };
+51 -2
View File
@@ -34,6 +34,7 @@ import {
markSeen, markSeen,
loadRegistrations, loadRegistrations,
matchRegistration, matchRegistration,
pidAlive,
} from "../src/scan.mjs"; } from "../src/scan.mjs";
import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs"; import { writeRegistration, makeRegistration } from "../../seat/src/seat.mjs";
@@ -444,7 +445,7 @@ test("registration: overrides task, project and workspace; every source says reg
workspace: "/registered/workspace", workspace: "/registered/workspace",
harness: "pi", harness: "pi",
tmux: { socket: null, session: "a" }, tmux: { socket: null, session: "a" },
pid: 4242, pid: process.pid,
now: () => new Date("2026-09-12T13:00:00Z"), now: () => new Date("2026-09-12T13:00:00Z"),
}); });
@@ -459,7 +460,8 @@ test("registration: overrides task, project and workspace; every source says reg
assert.ok(rec.registered, "registered must be non-null once a matching registration is passed"); assert.ok(rec.registered, "registered must be non-null once a matching registration is passed");
assert.equal(rec.registered.startedAt, registration.startedAt); assert.equal(rec.registered.startedAt, registration.startedAt);
assert.equal(rec.registered.harness, "pi"); assert.equal(rec.registered.harness, "pi");
assert.equal(rec.registered.pid, 4242); assert.equal(rec.registered.pid, process.pid);
assert.equal(rec.registered.alive, true, "the default pid probe sees this test process");
assert.deepEqual(rec.registered.tmux, { socket: null, session: "a" }); assert.deepEqual(rec.registered.tmux, { socket: null, session: "a" });
assert.equal(rec.registered.layout, "repo"); assert.equal(rec.registered.layout, "repo");
assert.equal(rec.registered.launchScript, join(root, "seat-dir", "launch.sh")); assert.equal(rec.registered.launchScript, join(root, "seat-dir", "launch.sh"));
@@ -501,6 +503,52 @@ test("registration: empty task and null project/workspace leave the derived valu
assert.notEqual(rec.registered, null, "an empty task and null project/workspace still leave a registration attached"); assert.notEqual(rec.registered, null, "an empty task and null project/workspace still leave a registration attached");
}); });
test("registration: a record whose pid is gone is stale; derived values win, sources say derived, registered stays with alive false; a pid the probe cannot decide is not stale; pidAlive itself", () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const registration = makeRegistration({
resolved: { seat: "a", project: "resolved-project", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/resolved/workspace" },
task: "registered task", project: "registered-project", workspace: "/registered/workspace", harness: "pi", pid: 4242,
});
const probed = [];
const rec = scanAgent(spec, { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW, registration, isPidAlive: (pid) => { probed.push(pid); return false; } });
assert.deepEqual(probed, [4242], "the probe is asked about the recorded pid");
assert.equal(rec.task, "derived task");
assert.equal(rec.taskSource, "first-user-message");
assert.equal(rec.activeProject, null, "no git root above /derived/cwd");
assert.equal(rec.activeProjectSource, null);
assert.equal(rec.workspace, "/derived/cwd");
assert.equal(rec.workspaceSource, "session-cwd");
assert.ok(rec.registered, "a stale record is still reported, not hidden");
assert.equal(rec.registered.alive, false);
assert.equal(rec.registered.pid, 4242);
assert.equal(rec.registered.startedAt, registration.startedAt);
// null from the probe (no pid recorded) keeps the override.
const undecided = scanAgent(spec, { isAlive: () => true, now: GATE_NOW, registration, isPidAlive: () => null });
assert.equal(undecided.task, "registered task");
assert.equal(undecided.registered.alive, null);
// scan(): the stale row is listed under registrationStale and still under registered.
const boardDir = join(root, "board");
const seatsDir = join(root, "seats");
writeRegistration(seatsDir, registration);
const index = scan([spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW, isPidAlive: () => false });
assert.deepEqual(index.registered, ["p/a"]);
assert.deepEqual(index.registrationStale, ["p/a"]);
assert.equal(index.sessions[0].taskSource, "first-user-message");
assert.equal(pidAlive(process.pid), true);
assert.equal(pidAlive(null), null);
assert.equal(pidAlive(0), null);
assert.equal(pidAlive(-1), null);
});
test("registration: no registration leaves the Gate A fields exactly as before, and registered is null", () => { test("registration: no registration leaves the Gate A fields exactly as before, and registered is null", () => {
const root = makeRoot(); const root = makeRoot();
const repo = join(root, "repo"); const repo = join(root, "repo");
@@ -643,6 +691,7 @@ test("scan: writes the registration override to disk; index.json carries registe
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} }; const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const index = scan([spec], { boardDir, seatsDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW }); const index = scan([spec], { boardDir, seatsDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.deepEqual(index.registered, ["p/a"]); assert.deepEqual(index.registered, ["p/a"]);
assert.deepEqual(index.registrationStale, [], "a record with pid null is never stale");
assert.deepEqual(index.registrationErrors, []); assert.deepEqual(index.registrationErrors, []);
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")); const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
assert.equal(onDisk.task, "registered task"); assert.equal(onDisk.task, "registered task");
+1 -1
View File
@@ -567,7 +567,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
const row = r.stdout.split("\n").find((l) => l.includes("agent1")); const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`); assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
assert.equal(row[0], "s"); assert.equal(row[0], "s");
assert.match(r.stdout, /1 seen, 0 registered\)\s*$/m); assert.match(r.stdout, /1 seen, 0 registered, 0 stale\)\s*$/m);
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+10 -2
View File
@@ -56,7 +56,8 @@ until `--layout` says which. `dataRoot` comes from `~/.config/mosaic-dev/config.
The record is rewritten on every launch. It is written before the launch The record is rewritten on every launch. It is written before the launch
script runs, so a launch the script itself refuses (a failed `--check`-style script runs, so a launch the script itself refuses (a failed `--check`-style
precondition, a missing context file) still leaves a record with a pid that precondition, a missing context file) still leaves a record with a pid that
is no longer running; the next launch replaces it. It is a launch record, is no longer running; the next launch replaces it, and the board treats a
record whose pid is gone as stale (shown, but the derived values win). It is a launch record,
not a run record: it is not evidence, it holds one seat's latest launch only, and it not a run record: it is not evidence, it holds one seat's latest launch only, and it
lives outside `<dataRoot>/board/` because the board never writes here and a lives outside `<dataRoot>/board/` because the board never writes here and a
scan never changes it. scan never changes it.
@@ -116,7 +117,14 @@ value, and the row's `taskSource`, `activeProjectSource` or
workspace in the record leaves the derived value in place. Rows without a workspace in the record leaves the derived value in place. Rows without a
registration are unchanged. A malformed record is reported in registration are unchanged. A malformed record is reported in
`registrationErrors` on the index and skipped; it never takes the board down `registrationErrors` on the index and skipped; it never takes the board down
and it is never treated as absent silently. and it is never treated as absent silently. A record whose pid is no
longer running is stale: still shown on the Registered line, but it does
not override anything.
The repository launch scripts are the only launchers that register.
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
2026-09-12); a fleet seat can still be registered by hand with
`scripts/mosaic launch <seat-dir>`.
## Exit codes ## Exit codes
+1 -1
View File
@@ -4,7 +4,7 @@
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...] // [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH] // mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
// //
// `launch` writes <dataRoot>/seats/<seat>/registration.json, then replaces // `launch` writes <dataRoot>/seats/<layout>/<seat>/registration.json, then replaces
// itself with the seat's launch.sh, unchanged, with everything after `--` as // itself with the seat's launch.sh, unchanged, with everything after `--` as
// its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch // its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch
// script that sees that variable is already registered and must not call // script that sees that variable is already registered and must not call
+5 -1
View File
@@ -7,6 +7,10 @@ import { join, resolve, basename } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const source = resolve(import.meta.dirname, '..'); const source = resolve(import.meta.dirname, '..');
// Every spawn below must name the fixture config explicitly. Anything that
// forgets inherits these and fails instead of registering into a real data root.
process.env.MOSAIC_CONFIG = join(tmpdir(), 'mosaic-launch-test-no-config.json');
process.env.MOSAIC_LAUNCH_REGISTERED = '';
for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch context, resources, session recovery, and refusals`, () => { for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch context, resources, session recovery, and refusals`, () => {
const root = mkdtempSync(join(tmpdir(), `${agent} launch `)); const root = mkdtempSync(join(tmpdir(), `${agent} launch `));
try { try {
@@ -127,7 +131,7 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag
assert.notEqual(launch('', false).status, 0); assert.notEqual(launch('', false).status, 0);
const locked = spawnSync('script', ['-q', '-e', '-c', const locked = spawnSync('script', ['-q', '-e', '-c',
`flock .pi/state/${agent}/launch.lock bash agents/${agent}/launch.sh --fresh`, '/dev/null'], `flock .pi/state/${agent}/launch.lock bash agents/${agent}/launch.sh --fresh`, '/dev/null'],
{ cwd: root, encoding: 'utf8', timeout: 10000 }); { cwd: root, env: { ...process.env, MOSAIC_CONFIG: join(root, 'config.json') }, encoding: 'utf8', timeout: 10000 });
assert.notEqual(locked.status, 0); assert.notEqual(locked.status, 0);
assert.match(locked.stdout, new RegExp(`another ${agent} TUI`)); assert.match(locked.stdout, new RegExp(`another ${agent} TUI`));
writeFileSync(join(root, 'alternate.md'), 'alternate user context\n'); writeFileSync(join(root, 'alternate.md'), 'alternate user context\n');
+4
View File
@@ -7,6 +7,10 @@ import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const source = resolve(import.meta.dirname, '..'); const source = resolve(import.meta.dirname, '..');
// Every spawn below must name the fixture config explicitly. Anything that
// forgets inherits these and fails instead of registering into a real data root.
process.env.MOSAIC_CONFIG = join(tmpdir(), 'mosaic-launch-test-no-config.json');
process.env.MOSAIC_LAUNCH_REGISTERED = '';
test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusals', () => { test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusals', () => {
const root = mkdtempSync(join(tmpdir(), 'rocko launch ')); const root = mkdtempSync(join(tmpdir(), 'rocko launch '));
try { try {