Spaces:
Running
Running
File size: 11,286 Bytes
722c43c c258697 722c43c c258697 722c43c c258697 722c43c c258697 722c43c c258697 722c43c c258697 722c43c c258697 722c43c c258697 722c43c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | 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;
}
|