Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)
First step-3 refinement from Jason's daily use. Liveness now lists the panes of the agent's tmux session and counts it alive only if a pane runs pi, so killed pi sessions whose tmux session still exists show offline instead of waiting. A "Seen" button on waiting and error rows stores the row's lastActivity in <dataRoot>/board/seen.json (clicks only, never rewritten by a scan, fail closed if corrupt) and drops the row from "Waiting on you" until the agent writes anything newer; "Unsee" reverses it. New POST /api/seen route: JSON only, 4 KB limit, 400 on bad input. Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED; receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -5,11 +5,13 @@
|
||||
// working - the agent is in the middle of a turn (thinking or running tools)
|
||||
// waiting - the agent finished its turn; it is your move
|
||||
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
|
||||
// offline - no live tmux session for this agent
|
||||
// offline - no tmux session for this agent, or its session no longer runs pi
|
||||
// idle - the agent is live but has no conversation yet
|
||||
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
|
||||
//
|
||||
// Board files are derived and rewritable. They are not run records.
|
||||
// Board files are derived and rewritable. They are not run records. The one
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
@@ -108,17 +110,82 @@ export function deriveState({ alive, session }) {
|
||||
return "working";
|
||||
}
|
||||
|
||||
export function tmuxIsAlive({ socket, session }) {
|
||||
// Programs that count as a live pi agent in a tmux pane. A tmux session that
|
||||
// still exists but only runs a shell (or another harness) is not alive: its
|
||||
// pi session log is history, not status.
|
||||
export const PI_COMMANDS = Object.freeze(["pi"]);
|
||||
|
||||
export function panesRunPi(listPanesOutput) {
|
||||
return String(listPanesOutput)
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.some((cmd) => PI_COMMANDS.includes(cmd));
|
||||
}
|
||||
|
||||
// true: a pane in the tmux session runs pi. false: no such session, or no pane
|
||||
// runs pi. null: tmux could not be run at all (reported as "unknown", never
|
||||
// assumed alive). `exec` is injectable for tests.
|
||||
export function tmuxIsAlive({ socket, session }, { exec = spawnSync } = {}) {
|
||||
const args = [];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("has-session", "-t", `=${session}`);
|
||||
const r = spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}");
|
||||
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
if (r.error) return null;
|
||||
return r.status === 0;
|
||||
if (r.status !== 0) return false;
|
||||
return panesRunPi(r.stdout ?? "");
|
||||
}
|
||||
|
||||
// "Seen" marks: { "<project>/<agent>": "<lastActivity ISO>" }. A mark only
|
||||
// applies while the agent's newest message still has that timestamp; anything
|
||||
// the agent writes afterwards clears it automatically.
|
||||
export function seenKey(rec) {
|
||||
return `${rec.project}/${rec.agent}`;
|
||||
}
|
||||
|
||||
export function seenPath(boardDir) {
|
||||
return join(boardDir, "seen.json");
|
||||
}
|
||||
|
||||
// Fail closed: a present but unreadable seen.json refuses the scan rather than
|
||||
// silently dropping every mark.
|
||||
export function loadSeen(boardDir) {
|
||||
const path = seenPath(boardDir);
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function saveSeen(boardDir, marks) {
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(seenPath(boardDir), marks);
|
||||
}
|
||||
|
||||
// Set or clear one mark. Returns the updated map.
|
||||
export function markSeen(boardDir, { project, agent, lastActivity, seen = true }) {
|
||||
for (const [name, v] of Object.entries({ project, agent, lastActivity })) {
|
||||
if (typeof v !== "string" || v.length === 0 || v.length > 512) throw new ConfigError(`${name} must be a non-empty string`);
|
||||
}
|
||||
if (project.includes("/")) throw new ConfigError("project must not contain '/'");
|
||||
if (typeof seen !== "boolean") throw new ConfigError("seen must be true or false");
|
||||
const marks = loadSeen(boardDir);
|
||||
const key = seenKey({ project, agent });
|
||||
if (seen) marks[key] = lastActivity;
|
||||
else delete marks[key];
|
||||
saveSeen(boardDir, marks);
|
||||
return marks;
|
||||
}
|
||||
|
||||
// One agent -> one status record.
|
||||
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() } = {}) {
|
||||
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(), seen = {} } = {}) {
|
||||
const alive = isAlive(spec.tmux);
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
const session = file ? readSession(file) : null;
|
||||
@@ -126,11 +193,14 @@ export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date()
|
||||
const scannedAt = now();
|
||||
const lastActivity = session?.lastTimestamp ?? null;
|
||||
const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null;
|
||||
const needsYou = state === "waiting" || state === "error";
|
||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
state,
|
||||
waitingOnYou: state === "waiting" || state === "error",
|
||||
waitingOnYou: needsYou && !isSeen,
|
||||
seen: isSeen,
|
||||
alive,
|
||||
tmux: spec.tmux,
|
||||
sessionFile: file,
|
||||
@@ -174,7 +244,8 @@ function writeAtomic(path, data) {
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now }));
|
||||
const seen = loadSeen(boardDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -184,7 +255,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
const index = {
|
||||
generatedAt,
|
||||
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map((r) => `${r.project}/${r.agent}`),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
|
||||
seen: records.filter((r) => r.seen).map(seenKey),
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
Reference in New Issue
Block a user