/** * Durable wake records (R-SLEEP-5). * * A wake subscription is a file under `.pi/agi/.runtime/wake/.json`. In-memory * timers lose the wake across a restart, and unattended operation is exactly the case * where a restart happens with nobody watching: a tick or a timed sleep armed before a * crash is re-armed on the next `session_start`, and one whose `firesAt` has already * passed fires immediately (E64f). * * Two properties are load-bearing and easy to lose: * * - **R-SLEEP-6 (remove then send).** `settle()` deletes the record and only then * hands it back for delivery. Reversed, a send that fails after a successful write * re-fires the same wake forever. Losing one wake is recoverable on the next tick; * an infinite re-fire is not. (Ported from nicobailon; the ordering *is* the safety * property — P23.) * - **R-SLEEP-7 (session scoping).** Every record carries its arming `sessionId`. A * record from another session is ignored and garbage-collected (E65), or a wake * armed in one conversation fires into an unrelated one. */ import * as fs from "node:fs"; import * as path from "node:path"; import { randomBytes } from "node:crypto"; import { writeJsonAtomic } from "../worker/status.ts"; export const WAKE_SCHEMA_VERSION = 1; export type WakeKind = "tick" | "sleep" | "worker_check" | "worker" | "attention"; export interface WakeRecord { schemaVersion: number; token: string; kind: WakeKind; sessionId: string; armedAt: string; /** Absent for a `worker` record: it fires on an event, not on a clock. */ firesAt?: string; runId?: string; attentionTrigger?: string; attentionDetail?: string; /** R-TOOL-21d: what the orchestrator said it was waiting for, for the UI and the payload. */ note?: string; /** R-TOOL-21d: same-wait cycle count at arming time. */ streak?: number; /** The clamped duration actually slept, so a re-armed record can report it. */ durationMs?: number; } export function wakeDir(cwd: string): string { return path.join(cwd, ".pi", "agi", ".runtime", "wake"); } function wakeFile(cwd: string, token: string): string { return path.join(wakeDir(cwd), `${token}.json`); } /** * R-SEC-7: the token becomes a path segment. It is generated here rather than * supplied, but `remove()` and `read()` accept one from disk, so it is validated on * the way in as well as on the way out. */ const TOKEN_PATTERN = /^w_[0-9a-f]{16,64}$/; export function newWakeToken(random = randomBytes(10)): string { return `w_${random.toString("hex")}`; } export function isValidToken(token: string): boolean { return TOKEN_PATTERN.test(token) && !token.includes("..") && !token.includes("/") && !token.includes("\\"); } export interface ArmOptions { kind: WakeKind; sessionId: string; firesAt?: number; runId?: string; attentionTrigger?: string; attentionDetail?: string; note?: string; streak?: number; durationMs?: number; now?: number; token?: string; } /** Write a durable record. Returns it, or undefined when the write failed (E37). */ export function armWake(cwd: string, options: ArmOptions): WakeRecord | undefined { const now = options.now ?? Date.now(); const token = options.token ?? newWakeToken(); if (!isValidToken(token)) return undefined; const record: WakeRecord = { schemaVersion: WAKE_SCHEMA_VERSION, token, kind: options.kind, sessionId: options.sessionId, armedAt: new Date(now).toISOString(), ...(options.firesAt === undefined ? {} : { firesAt: new Date(options.firesAt).toISOString() }), ...(options.runId === undefined ? {} : { runId: options.runId }), ...(options.attentionTrigger === undefined ? {} : { attentionTrigger: options.attentionTrigger }), ...(options.attentionDetail === undefined ? {} : { attentionDetail: options.attentionDetail }), ...(options.note === undefined ? {} : { note: options.note }), ...(options.streak === undefined ? {} : { streak: options.streak }), ...(options.durationMs === undefined ? {} : { durationMs: options.durationMs }), }; try { writeJsonAtomic(wakeFile(cwd, token), record); } catch { return undefined; } return record; } export function readWake(cwd: string, token: string): WakeRecord | undefined { if (!isValidToken(token)) return undefined; let raw: string; try { raw = fs.readFileSync(wakeFile(cwd, token), "utf8"); } catch { return undefined; } return parseWake(raw, token); } export function parseWake(raw: string, expectedToken?: string): WakeRecord | undefined { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const record = parsed as Record; if (record.schemaVersion !== WAKE_SCHEMA_VERSION) return undefined; const kind = record.kind; if (kind !== "tick" && kind !== "sleep" && kind !== "worker_check" && kind !== "worker" && kind !== "attention") return undefined; if (typeof record.token !== "string" || !isValidToken(record.token)) return undefined; if (expectedToken !== undefined && record.token !== expectedToken) return undefined; if (typeof record.sessionId !== "string" || typeof record.armedAt !== "string") return undefined; return record as unknown as WakeRecord; } /** * R-SLEEP-6. Delete first, then hand the record back to be sent. A caller that * receives `undefined` must not send: either the record was never there, or another * path already claimed it, and a double send is a double wake. */ export function settleWake(cwd: string, token: string): WakeRecord | undefined { const record = readWake(cwd, token); if (record === undefined) return undefined; if (!removeWake(cwd, token)) { // The delete failed, so we cannot promise the record will not be re-read on the // next start. Refusing to send is the safe direction: R-SLEEP-6 trades one lost // wake (recovered by the next tick) against an infinite re-fire. return undefined; } return record; } export function removeWake(cwd: string, token: string): boolean { if (!isValidToken(token)) return false; try { fs.unlinkSync(wakeFile(cwd, token)); return true; } catch (error) { // Already gone counts as removed: the postcondition R-SLEEP-6 needs is "this // record will not fire again", and an absent file satisfies it. return (error as NodeJS.ErrnoException).code === "ENOENT"; } } export interface LoadedWakes { /** Records armed by this session, ordered by `firesAt` then token. */ own: WakeRecord[]; /** R-SLEEP-7: records from another session, already deleted. */ discarded: WakeRecord[]; } /** * Read every record, dropping the ones this session must not honour. Called on * `session_start` to re-arm (R-SLEEP-5) and to garbage-collect (E65). */ export function loadWakes(cwd: string, sessionId: string): LoadedWakes { const dir = wakeDir(cwd); let names: string[]; try { names = fs.readdirSync(dir); } catch { return { own: [], discarded: [] }; } const own: WakeRecord[] = []; const discarded: WakeRecord[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const token = name.slice(0, -".json".length); const record = readWake(cwd, token); if (record === undefined) { // Unreadable, wrong schema, or a stray file. It can never be delivered, so // leaving it would leak one file per restart forever. try { fs.unlinkSync(path.join(dir, name)); } catch { // Best effort. } continue; } if (record.sessionId !== sessionId) { removeWake(cwd, token); discarded.push(record); continue; } own.push(record); } own.sort((a, b) => { const at = a.firesAt === undefined ? Number.POSITIVE_INFINITY : Date.parse(a.firesAt); const bt = b.firesAt === undefined ? Number.POSITIVE_INFINITY : Date.parse(b.firesAt); if (at !== bt) return at - bt; return a.token.localeCompare(b.token); }); return { own, discarded }; } /** E64g: a sleep or tick that outlived its purpose is disarmed, not left to fire. */ export function clearWakes(cwd: string, predicate: (record: WakeRecord) => boolean): WakeRecord[] { const dir = wakeDir(cwd); let names: string[]; try { names = fs.readdirSync(dir); } catch { return []; } const cleared: WakeRecord[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const record = readWake(cwd, name.slice(0, -".json".length)); if (record === undefined || !predicate(record)) continue; if (removeWake(cwd, name.slice(0, -".json".length))) cleared.push(record); } return cleared; } export function wakeIsDue(record: WakeRecord, now = Date.now()): boolean { if (record.firesAt === undefined) return false; const at = Date.parse(record.firesAt); return !Number.isNaN(at) && at <= now; }