thomwolf's picture
thomwolf HF Staff
Review fixes: a work signal that means something, and never erase the record
c258697
Raw
History Blame Contribute Delete
11.3 kB
import fs from 'node:fs';
import path from 'node:path';
import { DATA_DIR, PASSIVE_CLIS, isRemote } from './config.js';
import { list as listSessions, get as getSession } from './sessions.js';
import { isRunning, paneRootPid, ensureRunning, ghosttyReady } from './runner.js';
import { traceDigests } from './traces.js';
// The server owns every PTY, so a Space sleep, a reboot, or a factory reset ends
// all of them at once. Files and conversations survive on the bucket, but coming
// back means clicking each agent again — and a shell that was running something
// is simply gone, with nothing to show it ever mattered.
//
// So keep a RUNSTATE snapshot on the bucket: which sessions were alive, and
// which had a process actually running in them. On the next boot that snapshot
// is the record of what was up when the lights went out, and the sessions that
// were still yours get started again — detached, each resuming its own pinned
// conversation (runner.commandFor) with its scrollback replayed from the
// terminal history checkpoint. Everything else stays stopped: a restart is not
// an excuse to boot every agent you ever created.
//
// "revive", not "restore": in this codebase restore already means replaying a
// terminal's serialized grid to a viewer, which is a different thing entirely.
const RUNSTATE_FILE = path.join(DATA_DIR, 'runstate.json');
// How often the snapshot is refreshed. Frequent enough that a sleep loses at
// most half a minute of truth, rare enough to be invisible on the FUSE bucket.
const SNAPSHOT_MS = 30_000;
// A staggered boot: each revived session is a PTY plus a whole CLI booting, and
// launching them in one burst is what would make a restart feel like a crash.
const REVIVE_GAP_MS = 4000;
// Reviving is meant to bring your workbench back, not to recreate a fleet.
// Beyond this we start the most recently active ones and SAY so in the logs
// rather than quietly pretending the rest weren't running.
const MAX_REVIVE = 12;
// A Space that has been down for a season shouldn't wake up mid-thought. The
// recency rule bounds itself; this is the backstop for the work-in-flight one,
// whose whole point is that it carries no clock.
const STALE_SNAPSHOT_DAYS = 30;
// The snapshot as it was when this process started — read once, before the
// watch below starts overwriting it.
let previous = null;
let lastWritten = '';
// Boot revivals are staggered, so the first new snapshot must wait for them:
// writing at 30s would record a half-revived Space as the whole truth. Set in
// init() so it holds on EVERY boot path — the ones that revive nothing because
// something went wrong (no engine, Space locked) are exactly the ones where
// erasing the record does the most damage.
let settleAt = 0;
/** Load the previous boot's snapshot. Call once, before startRunstateWatch(). */
export function init() {
try {
const j = JSON.parse(fs.readFileSync(RUNSTATE_FILE, 'utf8'));
previous = j && typeof j === 'object' && j.sessions ? j : null;
} catch { previous = null; }
// Hold the first write for a full cycle from here, whatever happens next. A
// boot that revives nothing — engine missing, Space still locked, revive
// switched off — must not answer by wiping the record of what was running:
// that record is the only reason the next boot can do better.
settleAt = Date.now() + SNAPSHOT_MS;
return previous;
}
// Is anything running INSIDE this shell — a foreground command, or a `make &`
// left in the background? The pane's root process is the interactive bash, and
// anything it started is a child of it, which Linux hands us directly. One small
// procfs read, no subprocess, no /proc walk.
//
// SHELLS ONLY, on purpose. "Pane root == the thing you're talking to" is false
// for most agent CLIs: `codex` is a `#!/usr/bin/env node` launcher that keeps
// the native binary as a permanent child, and every `cont || exec run` launch
// shape leaves bash as the pane root with the CLI as its child. Both report a
// child forever, which would make the window mean nothing for those sessions —
// they would revive on every restart regardless. Agents don't need this signal
// anyway: they have a transcript, so the recency rule can see them. A shell has
// nothing else, which is the whole reason this rule exists.
function hasLiveChildren(pid) {
if (!pid) return false;
try { return fs.readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().length > 0; }
catch { return false; } // no procfs / process already gone
}
// A terminal answers the TUI's questions by itself: on attach, xterm replies to
// device-attribute and cursor-position queries, and those replies travel the
// same socket frame as your keystrokes (the client sends all of onData; only its
// own control-claiming path distinguishes real keys). Counting them would make
// "you sent this session something" mean "a pane was open on it", which is not
// the question the revive window asks. Match only the shapes a terminal EMITS in
// reply — DA/CPR/window-report/DECRPM and DCS/OSC answers — never a key: arrow
// keys and friends end in uppercase A-D or `~`, none of which appear here.
const REPLY = /^(?:\x1b\[[?>!]?[0-9;]*\$?[cRty]|\x1bP[\s\S]*?\x1b\\|\x1b\][\s\S]*?(?:\x07|\x1b\\))+$/;
export function isTerminalReply(d) {
return typeof d === 'string' && d.length > 0 && REPLY.test(d);
}
/** What is alive right now, and where something is actually running. */
function snapshot() {
const sessions = {};
for (const s of listSessions()) {
// Passive panels are views, and a remote agent runs on someone's laptop —
// neither is a process this server could ever start.
if (PASSIVE_CLIS.includes(s.cli) || isRemote(s.cli)) continue;
if (!isRunning(s.id)) continue;
sessions[s.id] = {
running: true,
work: s.cli === 'shell' && hasLiveChildren(paneRootPid(s.id)),
};
}
return { at: new Date().toISOString(), sessions };
}
/**
* Write the snapshot now. Deliberately NOT hooked to SIGTERM: by the time a
* shutdown signal lands the PTYs may already be going down, so a final write is
* as likely to record an empty Space as a true one — and it would overwrite the
* good record with it. A 30s-stale truth beats a fresh lie.
*/
export function saveRunstate() {
const snap = snapshot();
// The set of live sessions changes rarely; don't rewrite the bucket for a
// timestamp nobody reads.
const body = JSON.stringify(snap.sessions);
if (body === lastWritten) return;
try {
const tmp = `${RUNSTATE_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(snap, null, 2));
fs.renameSync(tmp, RUNSTATE_FILE);
// Only once it's actually on disk: a FUSE write that threw must be retried
// next cycle, not remembered as the state of the file.
lastWritten = body;
} catch (e) { console.error('[runstate]', e && e.message); }
}
/**
* Keep the on-disk snapshot current for the next boot to read. Deliberately
* writes nothing for the first cycle: until the boot revivals have finished, the
* old snapshot is a truer record of what was running than the new one, and a
* Space that crash-loops on boot must not erase it.
*/
export function startRunstateWatch({ intervalMs = SNAPSHOT_MS } = {}) {
const t = setInterval(() => { if (Date.now() >= settleAt) saveRunstate(); }, intervalMs);
if (t.unref) t.unref();
return t;
}
/**
* Which of the snapshotted sessions deserve to come back, most recently used
* first. A session qualifies when it was alive in the snapshot AND either
* - you sent it something within `days` (your keystrokes in the pane, the
* Overview reply box, or a prompt in its transcript), or
* - something was still running in it at snapshot time.
* The first rule is what keeps a working set alive across a reboot; the second
* is what a shell running a long job leaves behind.
*
* Pure, and separate from the starting below, because this is the part with
* rules in it: the arguments are all facts, the answer is just a list.
*/
export function selectRevivable({ snapshot: snap, sessions, digests, alive, days, now = Date.now() }) {
const cutoff = now - days * 864e5;
const byId = new Map(sessions.map((s) => [s.id, s]));
const plan = [];
for (const [id, rec] of Object.entries((snap && snap.sessions) || {})) {
if (!rec || !rec.running) continue;
const s = byId.get(id);
if (!s) continue; // deleted since
if (PASSIVE_CLIS.includes(s.cli) || isRemote(s.cli)) continue;
if (alive.has(id)) continue; // already up somehow
// `lastInputAt` is what YOU typed (index.js records it); the digest covers
// prompts that arrived before we tracked that, and prompts from peers.
const d = digests.get(id);
const lastPrompt = Math.max(Number(s.lastInputAt) || 0, (d && d.lastPromptTs) || 0);
const recent = lastPrompt >= cutoff;
// `work` is only meaningful for a shell (see hasLiveChildren). Re-checked
// here rather than trusted, so a snapshot written before that was true
// can't revive an agent on a signal that was never valid for it.
const busy = !!rec.work && s.cli === 'shell';
if (!recent && !busy) continue;
plan.push({ id, name: s.name, cli: s.cli, at: lastPrompt, why: recent ? 'prompted recently' : 'work in flight' });
}
return plan.sort((a, b) => b.at - a.at);
}
/** Start the sessions that were running when this Space last went down. */
export async function reviveOnBoot({ enabled = true, days = 3 } = {}) {
if (!enabled) return [];
if (!previous || !previous.sessions) return [];
// No terminal engine means ensureRunning throws for every session; say it once
// instead of a dozen times.
if (!ghosttyReady()) {
console.warn('[revive] no terminal engine — starting nothing');
return [];
}
const age = Date.now() - (Date.parse(previous.at) || 0);
if (age > STALE_SNAPSHOT_DAYS * 864e5) {
console.log(`[revive] last snapshot is ${Math.round(age / 864e5)} days old — starting nothing`);
return [];
}
let digests = new Map();
try { digests = await traceDigests(); } catch { /* no transcripts is fine */ }
const sessions = listSessions();
const plan = selectRevivable({
snapshot: previous,
sessions,
digests,
alive: new Set(sessions.filter((s) => isRunning(s.id)).map((s) => s.id)),
days,
});
if (!plan.length) return [];
const start = plan.slice(0, MAX_REVIVE);
const skipped = plan.slice(MAX_REVIVE);
console.log(`[revive] ${start.length} session(s) were running at shutdown — starting them again`);
if (skipped.length) {
console.log(`[revive] NOT starting ${skipped.length} more (cap ${MAX_REVIVE}): ${skipped.map((p) => p.name).join(', ')}`);
}
settleAt = Date.now() + start.length * REVIVE_GAP_MS + 5000;
start.forEach((p, i) => {
const t = setTimeout(() => {
const s = getSession(p.id);
if (!s) return; // deleted between the plan and its turn
try {
ensureRunning(s);
console.log(`[revive] ${p.name} (${p.cli}) — ${p.why}`);
} catch (e) { console.error(`[revive] ${p.name}:`, e && e.message); }
}, i * REVIVE_GAP_MS);
if (t.unref) t.unref();
});
return start;
}