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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user