UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

896 lines 42.9 kB
import { join } from 'node:path'; import { hostname } from 'node:os'; import { nodeFs } from '../node-fs.js'; import { agentIdFromWorktreeDir, isWorktreeDirName, DATA_BRANCH } from '../branch-names.js'; /** * Persisted orchestration state (#211). The dashboard is a pure projection of the * {@link FrameworkEvent} stream, so persisting *is* durably logging that stream: * the stack rationale, the loop status, and the decisions ledger are all events * that already flow through it. We store the log append-only and rehydrate a * restarted dashboard by replaying it into a fresh stream — no separate state * model to keep in sync. Per the sync, we do **not** persist the agent's chat * transcript (Claude Code owns that); only our own orchestration events. */ /** * The directory, under the workspace root, that holds the persisted agent (#313): * one dir holds both the transient agent state (events.jsonl / agent.json) and the * committed agent archive (agents/, #1179); a seeded `.the-framework/.gitignore` * keeps the agent state untracked. */ export const FRAMEWORK_DIR = '.the-framework'; /** * Per-run worktrees live under `<repo>/.the-framework/branches/` (#736/#1580), each in a dir * named as its branch. Kept out of git by the install-time `.the-framework/.gitignore` (`*` rule, * #313), so a worktree's checkout never shows up as dirty in the parent. Declared here beside * {@link FRAMEWORK_DIR} rather than in `worktree.ts`, which imports from this module: * {@link readLiveMetas} needs it to find the runs living in those worktrees, and the other * direction would be an import cycle. */ export const BRANCHES_DIR = 'branches'; /** The append-only event log: one {@link FrameworkEvent} per line (JSONL). */ export const EVENTS_FILE = 'events.jsonl'; /** A small snapshot for cheap status reads without replaying the whole log. */ export const META_FILE = 'agent.json'; /** * Where finished agents are archived, under both placements that name has: the lasting * `agents/<user>/` on the data branch's checkout (#1179/#1582), and the transient * `.the-framework/agents/` that an agent with no worktree of its own — or one archiving inside * its own throwaway checkout — writes into. {@link archiveDir}/{@link committedArchiveDir} are * where a caller picks; both are read when a project's history is listed. * * The live agent stays at `events.jsonl`/`agent.json` (the daemon tails it); on * {@link AgentStore.close} a copy lands here as `<id>.jsonl` + `<id>.json` (#303), giving the * history sidebar a per-agent log to replay. * * The name lives here rather than in `sessions.ts`, which owns the per-user naming: that module * reads the store, so the constant travelling the other way would be a cycle. */ export const ARCHIVE_DIR = 'agents'; /** Filesystem-safe, lexicographically-sortable agent id from an ISO start time. */ export function agentIdFromStartedAt(startedAt) { // ISO is fixed-width, so replacing the `:`/`.` separators keeps lexical order // in step with chronological order — the history list sorts by id alone. return startedAt.replace(/[:.]/g, '-'); } /** An agent id is path-safe: no separators or traversal, only our own charset. */ export function isSafeAgentId(id) { return /^[A-Za-z0-9_-]+$/.test(id); } /** * The inverse of {@link agentIdFromStartedAt}, for a caller that has the id but not the meta * (#1251): the CLI's end-of-run handoff needs the start time to tell the agent's own PR from a * predecessor's on the same branch name. Undefined for an id that is not one of ours. */ export function startedAtFromAgentId(id) { const match = /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(id); return match ? `${match[1]}:${match[2]}:${match[3]}.${match[4]}Z` : undefined; } /** * Fold one event into the running {@link AgentMeta}. Pure, so the same derivation * drives both a live append and reconstructing meta from a replayed log. */ export function applyEventToMeta(meta, event, at) { const next = { ...meta, updatedAt: at }; switch (event.kind) { case 'session': next.driver = event.driver; next.workspace = event.workspace; if (event.sessionLink) next.sessionLink = event.sessionLink; // Per-leg (#1438): a leg that recorded no model leaves it unknown rather than // inheriting the prior leg's — the agent may have resolved a different default. if (event.model) next.model = event.model; else delete next.model; break; case 'session-update': next.sessionId = event.sessionId; if (event.sessionLink) next.sessionLink = event.sessionLink; break; case 'session-name': next.sessionName = event.name; break; case 'ready-for-merge': next.readyForMerge = true; break; case 'choice': next.pendingChoice = { id: event.id, title: event.title }; break; case 'choice-resolved': if (next.pendingChoice?.id === event.id) delete next.pendingChoice; break; case 'intent': next.intent = event.text; break; case 'browser-stream': next.browserStreamPort = event.port; break; case 'handoff-armed': next.handoff = { push: event.push, pr: event.pr, ...(event.merge !== undefined ? { merge: event.merge } : {}) }; break; case 'handoff': next.handoffReport = event.outcome; // Cleared on a non-skipped outcome rather than only ever set: a resumed run's second leg // can publish after its first skipped, and a stale `no-commits` on a published run is // exactly the lie the release must not act on. if (event.outcome === 'skipped') next.handoffSkip = event.reason; else delete next.handoffSkip; if (event.outcome !== 'failed' && event.merge) next.mergeOutcome = event.merge.outcome; break; case 'ticket': next.ticket = event.path; break; case 'pull-request': next.pr = { number: event.number, url: event.url }; break; case 'branch': next.branch = event.branch; break; case 'cloud-anchor': next.cloudAnchor = event.sha; break; case 'settled': next.settledAt = at; break; case 'driver': // Any new turn means the agent is working again, so the agent is no longer parked (#785). if (event.event.type === 'start') delete next.settledAt; break; case 'end': next.status = event.ok ? 'done' : event.stopped ? 'stopped' : 'failed'; delete next.pendingChoice; // a finished run is not awaiting anything delete next.settledAt; // nor is it waiting on you // The bridge dies with the agent, so a kept port would send the pane at whatever else // the OS handed that number next. delete next.browserStreamPort; break; default: break; } return next; } /** The seed meta an agent starts from, before any event is folded in. */ function freshMeta(startedAt, intent, owner, id, target, kind) { return { status: 'running', id: id && isSafeAgentId(id) ? id : agentIdFromStartedAt(startedAt), startedAt, updatedAt: startedAt, ...(owner ? { pid: owner.pid, host: owner.host } : {}), ...(intent ? { intent } : {}), // Only a non-local target travels; `local` is the default every reader already assumes. ...(target && target !== 'local' ? { target } : {}), ...(kind ? { kind } : {}), }; } /** * Parse a JSONL event log. A blank or malformed trailing line (e.g. a crash * mid-write) stops the read rather than throwing, so a partial agent still replays * everything up to the cut. */ function parseEventLog(raw) { const events = []; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; try { events.push(JSON.parse(trimmed)); } catch { break; // a torn last line from an interrupted write; keep what we have } } return events; } /** How many times a meta that would not parse is re-read before it is called corrupt (#1540). */ const TORN_META_READ_RETRIES = 2; /** The pause between those re-reads: long enough for the write that tore the read to land. */ const TORN_META_READ_DELAY_MS = 5; /** * Read + parse a persisted {@link AgentMeta} file, or `undefined` if missing/unreadable. * * Re-read on a parse failure (#1540). {@link writeMetaFile} closes this off at the source, so this * is the backstop rather than the defence: a meta written through an adapter with no `rename` is * still written in place, so a reader can catch it mid-truncate, and reporting that as `undefined` * makes a live agent *vanish* from every composed read for one poll. A torn read is transient by * construction, so ask again; a file still unparseable after the retries is genuinely corrupt and * yields `undefined`, exactly as before. */ async function readMetaFile(fs, path) { for (let attempt = 0;; attempt++) { if (!(await fs.exists(path))) return undefined; try { return JSON.parse(await fs.read(path)); } catch { if (attempt >= TORN_META_READ_RETRIES) return undefined; await new Promise(resolve => setTimeout(resolve, TORN_META_READ_DELAY_MS)); } } } /** * Write a {@link AgentMeta} file. The one owner of the on-disk encoding, symmetric to * {@link readMetaFile} — every meta write in this module goes through it. * * Written beside the target and renamed over it (#1540). A meta is rewritten by the agent that owns * it, over and over, while the daemon and every dashboard read poll it from another process; a * plain write truncates the file before it refills, so a reader landing in that window saw an * empty one and reported the agent *gone*. A rename swaps the whole file in one step, so a reader * gets either the entire previous meta or the entire new one and never a half of either — which * {@link readMetaFile}'s retry can only paper over, never prevent. An adapter with no `rename` * writes in place as before. */ async function writeMetaFile(fs, path, meta) { const contents = JSON.stringify(meta, null, 2) + '\n'; if (!fs.rename) return fs.write(path, contents); // Named for the writing process: an agent and the daemon teardown archiving it can both be // writing the same meta, and they must not share a scratch file and splice their writes into // one. `.tmp` also keeps it out of the archive listing, which takes only `.json`. const scratch = `${path}.${process.pid}.tmp`; await fs.write(scratch, contents); await fs.rename(scratch, path); } /** * The `end` event written on behalf of an agent whose process died without reporting one (#1359): * a crash, a `kill -9`, or the empty-event-loop exit a parked gate used to cause. Every reader * of the stream — the dashboard's outcome pill, its choice rail, the meta fold — keys "over" * off a single `end` event, so a death that skipped it left the agent's last question rendering * as answerable forever while its picks were read by nobody. */ function orphanEndEvent() { return { kind: 'end', ok: false, stopped: true, detail: 'its process died without reporting an end' }; } /** * Record a dead agent's missing ending in place (#1359): append the surrogate `end` event to the * checkout's live log and fold it into the meta via {@link applyEventToMeta} — so the status * flips to `stopped` and a `pendingChoice` the agent died holding expires exactly as a run-written * end would expire it. Best-effort on both writes: healing must never make a read throw. */ async function recordOrphanEnd(fs, dir, meta) { const event = orphanEndEvent(); await fs.append(join(dir, EVENTS_FILE), JSON.stringify(event) + '\n').catch(() => { }); const ended = applyEventToMeta(meta, event, new Date().toISOString()); await writeMetaFile(fs, join(dir, META_FILE), ended).catch(() => { }); return ended; } /** * Flip the live agent at `dir` to `stopped` and archive it, returning the stopped meta. The * shared tail of every self-heal: a `running` meta whose process is gone must both stop * showing as live and keep its history. The flip goes through {@link recordOrphanEnd}, so * the log gains the `end` event the dead process never wrote (#1359) before the archive * copies it. Best-effort on both writes — healing must never make a read throw. */ async function stopAndArchiveLive(fs, dir, meta) { const stopped = await recordOrphanEnd(fs, dir, meta); await archivePriorAgent(fs, dir).catch(() => { }); return stopped; } /** Rebuild {@link AgentMeta} from a full event log (used when resuming). */ export function metaFromEvents(events, startedAt) { let meta = freshMeta(startedAt); for (const event of events) meta = applyEventToMeta(meta, event, startedAt); return meta; } /** * Durable, append-only store for a single agent's orchestration events, plus a * derived {@link AgentMeta} snapshot. Writes are serialized through one tail * promise so an append and its meta rewrite never interleave; {@link close} * flushes that queue before the process exits. */ export class AgentStore { fs; dir; clock; tail = Promise.resolve(); meta; /** * The intent a continuation must keep (#762/#1467): a reopened session keeps its original * label, but a continuation's own `intent` event carries the resume message and would relabel * the row through {@link applyEventToMeta}'s normal refinement. Unset for a fresh session, * where that refinement stands. */ pinnedIntent; constructor(fs, dir, clock, startMeta) { this.fs = fs; this.dir = dir; this.clock = clock; this.meta = startMeta; } /** The event log path. */ get eventsPath() { return join(this.dir, EVENTS_FILE); } /** The meta snapshot path. */ get metaPath() { return join(this.dir, META_FILE); } /** * Open (creating `.the-framework/` if needed) under the workspace `cwd`. `fresh` * truncates any prior log for a new agent; the default preserves it so a resume * can {@link loadEvents}. */ static async open(cwd, opts = {}) { const fs = opts.fs ?? nodeStoreFs(); const dir = join(cwd, FRAMEWORK_DIR); const now = opts.now ?? new Date().toISOString(); await fs.mkdir(dir); const owner = opts.owner ?? { pid: process.pid, host: hostname() }; const clock = opts.clock ?? (() => new Date().toISOString()); const store = new AgentStore(fs, dir, clock, freshMeta(now, opts.intent, owner, opts.id, opts.target, opts.kind)); if (opts.continueAgent) { // Reopen: the log stays, the row keeps its original intent, and this process takes ownership // so a liveness probe (#716) reads the agent as alive rather than as an orphan. const prior = await readMetaFile(fs, store.metaPath); if (prior) { store.meta = { ...prior, status: 'running', pid: owner.pid, host: owner.host, updatedAt: now }; store.pinnedIntent = prior.intent; await store.writeMeta(); return store; } } if (opts.fresh) { // A new agent truncates the live log. First rescue the prior agent if it never // got archived (e.g. a crash exited before close), so no history is lost. await archivePriorAgent(fs, dir).catch(() => { }); await fs.write(store.eventsPath, ''); await store.writeMeta(); } return store; } /** * Append one event to the log and refresh the meta snapshot. Fire-and-forget at * the call site: internally chained so writes stay ordered. A failed write is * swallowed (persistence is best-effort — it must never break a live agent). */ append(event) { this.meta = applyEventToMeta(this.meta, event, this.clock()); // A continuation keeps the session's original label (#762) even though its own `intent` // event carries the resume message rather than a name (#1467). if (this.pinnedIntent) this.meta = { ...this.meta, intent: this.pinnedIntent }; this.tail = this.tail .then(() => this.fs.append(this.eventsPath, JSON.stringify(event) + '\n')) .then(() => this.writeMeta()) .catch(err => { console.error('[framework] failed to persist orchestration state:', err); }); return this.tail; } /** * Flush any queued writes, then archive this agent into `agents/` so it shows up in * the dashboard's history (#303). Both best-effort: persistence must never break * an agent, so an archive failure is logged, not thrown. */ async close() { await this.tail; try { await archiveAgent(this.fs, archiveDir(this.dir), this.meta, this.eventsPath); } catch (err) { console.error('[framework] failed to archive run history:', err); } } /** The current derived snapshot. */ snapshot() { return { ...this.meta }; } /** * Read and parse the persisted event log. A blank or malformed trailing line * (e.g. a crash mid-write) is skipped rather than throwing, so a partial agent * still replays everything up to the cut. Missing file yields `[]`. */ async loadEvents() { if (!(await this.fs.exists(this.eventsPath))) return []; return parseEventLog(await this.fs.read(this.eventsPath)); } /** Read the persisted meta snapshot, or `undefined` if none/unreadable. */ readMeta() { return readMetaFile(this.fs, this.metaPath); } writeMeta() { return writeMetaFile(this.fs, this.metaPath, this.meta); } } /** * The transient archive inside a `.the-framework` dir: what an agent's own worktree copy and the * crash rescue use. Untracked by design — the lasting copy lives on the data branch (#1582). */ function archiveDir(dir) { return join(dir, ARCHIVE_DIR); } /** * A user's lasting archive (#1179/#1582): `agents/<user>/` at the root of the data branch's * checkout. On the branch, so the history survives `git clean -fdx` AND never touches the code * history; per user, so two people's machines write side by side instead of conflicting. */ function committedArchiveDir(cwd, user) { return join(cwd, FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH, ARCHIVE_DIR, user); } /** Paths of an agent's archived log + meta inside one archive directory. */ function archivePaths(agentsDir, id) { return { events: join(agentsDir, `${id}.jsonl`), meta: join(agentsDir, `${id}.json`) }; } /** * Where one agent's archive actually sits, searched across {@link archiveDirs}, or `undefined` when * it is nowhere. An agent id alone no longer names a path: which user archived it decides that, and a * reader (the continue (#762), a removal) only has the id. */ async function findArchive(fs, cwd, agentId) { for (const agentsDir of await archiveDirs(fs, cwd)) { const paths = archivePaths(agentsDir, agentId); if (await fs.exists(paths.meta)) return paths; } return undefined; } /** * Every directory a project's archived agents may sit in, committed first: each user's * `agents/<user>/` on the data branch's checkout (#1582), then the transient * `.the-framework/agents/` an agent with no worktree archives into. * * Every user's archive is listed, not just the reader's — the history is a team-visible record of * what the agent has done to the repo, which is the point of committing it. * * A directory is recognized by having a readable archive child, so a stray file is simply not one * (readdir yields `[]` for anything that is not a directory). */ async function archiveDirs(fs, cwd) { const dirs = []; const committed = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR, DATA_BRANCH, ARCHIVE_DIR); for (const name of await fs.readdir(committed)) { const candidate = join(committed, name); if ((await fs.readdir(candidate)).length > 0) dirs.push(candidate); } dirs.push(join(cwd, FRAMEWORK_DIR, ARCHIVE_DIR)); return dirs; } /** * Copy an agent's live log + meta into `agentsDir` as `<id>.jsonl` / `<id>.json`. The live files * stay put (the daemon keeps tailing them until the next agent); this is a durable snapshot for * the history list. Idempotent per id. */ async function archiveAgent(fs, agentsDir, meta, eventsPath) { if (!isSafeAgentId(meta.id)) return; await fs.mkdir(agentsDir); const out = archivePaths(agentsDir, meta.id); const events = (await fs.exists(eventsPath)) ? await fs.read(eventsPath) : ''; await fs.write(out.events, events); await writeMetaFile(fs, out.meta, meta); } /** * Archive the agent currently sitting in the live files, unless it is already in * `agents/`. Used at the start of a fresh agent so a crash that skipped * {@link AgentStore.close} still leaves its history behind. */ async function archivePriorAgent(fs, dir) { const meta = await readMetaFile(fs, join(dir, META_FILE)); if (!meta?.id || !isSafeAgentId(meta.id)) return; if (await fs.exists(archivePaths(archiveDir(dir), meta.id).meta)) return; await archiveAgent(fs, archiveDir(dir), meta, join(dir, EVENTS_FILE)); } /** * Put an archived agent's history back where an agent reads it (#762), so a continued agent picks up its * own log rather than starting empty. The inverse of {@link archiveWorktreeAgent}: teardown moved the * history to the repo, and continuing needs it in the checkout again. * * A no-op when the worktree already holds a live agent (nothing to restore, and its log is newer), * or when there is no archive. Never throws. */ export async function restoreArchivedAgent(repo, worktree, agentId, fs = nodeStoreFs()) { try { if (!isSafeAgentId(agentId)) return false; const dir = join(worktree, FRAMEWORK_DIR); if (await fs.exists(join(dir, META_FILE))) return false; const archive = await findArchive(fs, repo, agentId); if (!archive) return false; await fs.mkdir(dir); await fs.write(join(dir, EVENTS_FILE), (await fs.exists(archive.events)) ? await fs.read(archive.events) : ''); await fs.write(join(dir, META_FILE), await fs.read(archive.meta)); return true; } catch { return false; } } /** * Every checkout directory on disk (#1580). Only the run branch spelling counts — the same * directory holds the rename links (#1589), which are views, not checkouts. Forgiving: a missing * root yields nothing. */ export async function worktreeDirEntries(cwd, fs = nodeStoreFs()) { const root = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR); const entries = []; for (const name of await fs.readdir(root).catch(() => [])) { const agentId = agentIdFromWorktreeDir(name); if (isWorktreeDirName(name) && isSafeAgentId(agentId)) entries.push({ path: join(root, name), agentId }); } return entries; } /** * The agent ids that have a worktree directory (#737/#1580). Forgiving — a project that never ran * concurrently has no such dir and yields `[]`. */ export async function listWorktreeDirs(cwd, fs = nodeStoreFs()) { return [...new Set((await worktreeDirEntries(cwd, fs)).map(entry => entry.agentId))]; } /** * Archive a worktree agent's history into the *main repo* (#737), returning the meta it archived. * * An agent writes its `agent.json` / `events.jsonl` inside its own worktree (#736), so deleting that * worktree would delete the agent's history with it. This copies it into the repo, which is the one * place the dashboard's history reads from, so teardown becomes safe. * * `user` files the copy under that user's lasting `agents/<user>/` on the data branch's checkout * (#1179/#1582) instead of the transient `agents/`. It is this copy, not the one the agent left in * its own worktree, that is meant to last: every agent in a git repo gets a worktree, so this is * the only archive of it that outlives the checkout. The caller owns getting it committed — the * daemon funnels this through the data branch's writer. The worktree's own copy deliberately * stays untracked — it would otherwise be committed onto the agent's branch as well and collide * with this one on merge. * * A meta still marked `running` is flipped to `stopped` first: this runs when the process is * already gone, so `running` means it died without closing (crash, kill -9), exactly the case * {@link reconcileOrphanedAgents} handles for the project path. Idempotent per id, and forgiving: * a worktree with no run, or an unreadable one, yields `undefined` rather than throwing. */ export async function archiveWorktreeAgent(worktree, repo, fs = nodeStoreFs(), branch, user) { try { const worktreeDir = join(worktree, FRAMEWORK_DIR); const live = await readMetaFile(fs, join(worktreeDir, META_FILE)); if (!live?.id || !isSafeAgentId(live.id)) return undefined; // The flip writes the worktree's own log + meta too (#1359): the death gains its `end` // event before the archive copies the log, so no reader — live tail or archived replay — // is left holding an open gate for a dead agent. const stopped = live.status === 'running' ? await recordOrphanEnd(fs, worktreeDir, live) : live; // The branch is read from the checkout by the caller and stamped here, because this is the // last moment it can be observed: the worktree is about to go (#799). const meta = branch ? { ...stopped, branch } : stopped; const dest = user ? committedArchiveDir(repo, user) : archiveDir(join(repo, FRAMEWORK_DIR)); await archiveAgent(fs, dest, meta, join(worktreeDir, EVENTS_FILE)); return meta; } catch { return undefined; } } /** * The archived log + meta paths of one agent, wherever it is filed, or `[]` when it is nowhere. * Exported so a caller that deletes a session (the dashboard's Remove) does not have to know which * user archived it — before #1179 the path was derivable from the id alone, and now it is not. */ export async function archivedAgentPaths(cwd, agentId, fs = nodeStoreFs()) { if (!isSafeAgentId(agentId)) return []; const archive = await findArchive(fs, cwd, agentId).catch(() => undefined); return archive ? [archive.meta, archive.events] : []; } /** Newest run first: an id sorts chronologically, so the id order IS the time order (no parse). */ const byIdDesc = (a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); /** * Whether an `<id>.json` archive entry is older than `since` going by its *name* — an id is the * run's start time, so the filename dates the record and most of a long history can be rejected * before it is ever read (#1607). * * Only a name that parses as one of our ids can reject: an id handed in from outside is not a * date, and is read like any other. The id is allocated when the run is spawned and `startedAt` * written when it first opens its store, so the name can be the older of the two by the length of * a spawn — a caller filtering on `startedAt` sees a record drop out at most that much early. */ function namedBefore(name, since) { const startedAt = startedAtFromAgentId(name.slice(0, -'.json'.length)); return startedAt !== undefined && Date.parse(startedAt) < since; } /** * Every `agents/*.json` archived meta with the path it was read from, torn/half-written entries * skipped. The one home of the archived-history read loop, shared by {@link listAgents} and the * boot reconcile. A missing/unreadable dir throws to the caller, as both callers always let it. * * `since` (epoch ms) drops the runs that started before it without reading them; see * {@link namedBefore}. */ async function readArchivedMetaEntries(fs, agentsDir, since) { const entries = []; for (const name of await fs.readdir(agentsDir)) { if (!name.endsWith('.json')) continue; if (since !== undefined && namedBefore(name, since)) continue; const path = join(agentsDir, name); try { entries.push({ path, meta: JSON.parse(await fs.read(path)) }); } catch { // torn/half-written meta — skip it } } return entries; } /** * A `running` meta whose owning process is provably gone (or unknowable on boot): the orphan * {@link reconcileOrphanedAgents} flips to `stopped`. A live pid on this host, or a non-running * meta, is left be. The narrowing lets a caller use the meta as present in the true branch. */ function isDeadRunningAgent(meta, isAlive) { return meta?.status === 'running' && ownerLiveness(meta, isAlive) !== 'live'; } /** * Every archived meta a project has, across all of {@link archiveDirs}, with the path it came from. * De-duplicated by agent id, first directory winning: the crash rescue archives into the transient * `agents/` and the close into the user's committed one, so an agent can sit in both places and the * history must show it once. The user directories are searched first, so the committed copy wins. */ async function readAllArchivedMetaEntries(fs, cwd, since) { const seen = new Set(); const entries = []; for (const agentsDir of await archiveDirs(fs, cwd)) { for (const entry of await readArchivedMetaEntries(fs, agentsDir, since).catch(() => [])) { if (seen.has(entry.meta.id)) continue; seen.add(entry.meta.id); entries.push(entry); } } return entries; } /** * List a project's archived agents, most-recent first: every user's committed archive plus the * transient `agents/`. The id sorts chronologically so no timestamp parse is needed. Missing or * unreadable dir/entries are skipped, never thrown. * * `since` (epoch ms) is for a caller that only wants recent runs — a poll on a cadence, not the * history list. It is answered from the filenames, so the records it excludes cost no read at all. */ export async function listAgents(cwd, fs = nodeStoreFs(), since) { const entries = await readAllArchivedMetaEntries(fs, cwd, since); return entries.map(entry => entry.meta).sort(byIdDesc); } /** * Whether a `running` meta's owning process is provably there, provably gone, or unknowable. * * `'unknown'` is the load-bearing third state (#716/#926): a meta with no `pid` (it predates the * field) or one owned by another host cannot be probed from here. The two callers treat it * differently on purpose — the boot reconcile flips an unknown to `stopped` (a fresh daemon * drives no in-flight run, and there is nothing better to go on), while the self-heal on read * leaves it alone (a routine read must not kill an agent another machine may own). */ function ownerLiveness(meta, isAlive) { if (meta.status !== 'running' || meta.pid === undefined || meta.host !== hostname()) return 'unknown'; return isAlive(meta.pid) ? 'live' : 'dead'; } /** * Reconcile runs a dead process left marked `running` — the live `agent.json`, an archived * `agents/*.json`, or an agent inside a worktree. Such an agent shows as active while nothing is left * to read its `control.jsonl`, so its Stop is a no-op. Each is flipped to `stopped`; the live * run is archived first (idempotent) so its history is kept. Returns how many were reconciled. * Best-effort: a read/write error skips that agent, never throws. * * An agent whose pid is alive on this host is left alone (#926). This used to flip every `running` * meta on the assumption that a fresh dashboard drives no in-flight run, which holds only while * exactly one is ever booted: a second one marked genuinely live agents as finished, giving them a * no-op Stop in the dashboard. A meta with no `pid` keeps the old behaviour, since there is * nothing better to go on. */ export async function reconcileOrphanedAgents(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { const dir = join(cwd, FRAMEWORK_DIR); let fixed = 0; // Archived agents stuck at `running` (e.g. a prior live agent the next agent never rescued), wherever // they are archived. Done before the live agent so its fresh archive isn't re-counted here. for (const { path, meta } of await readAllArchivedMetaEntries(fs, cwd)) { if (!isDeadRunningAgent(meta, isAlive)) continue; try { // The archived pair sits side by side (`<id>.json` + `<id>.jsonl`), so the surrogate end // (#1359) lands in both: the replayed log sees the agent finish, and the meta fold drops // the pendingChoice the agent died holding. const event = orphanEndEvent(); await fs.append(path.replace(/\.json$/, '.jsonl'), JSON.stringify(event) + '\n').catch(() => { }); await writeMetaFile(fs, path, applyEventToMeta(meta, event, new Date().toISOString())); fixed++; } catch { // write failed — best-effort, skip } } // The live agent: flip it, then archive so a crash that skipped close() still // leaves the stopped agent in the history list. const live = await readMetaFile(fs, join(dir, META_FILE)); if (isDeadRunningAgent(live, isAlive)) { await stopAndArchiveLive(fs, dir, live); fixed++; } // Runs living in worktrees (#736/#737). A daemon that died mid-run never ran its teardown, so // each of those agents is orphaned the same way — except its history sits inside the worktree, // where nothing reads it. Flip it in place (so the dashboard stops showing it as live) and copy // it into the repo's history. The worktree itself is left on disk: an agent that ended this way did // not end cleanly, and those are kept for inspection. Removing one is an explicit action. for (const entry of await worktreeDirEntries(cwd, fs)) { const worktreeDir = join(entry.path, FRAMEWORK_DIR); const meta = await readMetaFile(fs, join(worktreeDir, META_FILE)); if (!isDeadRunningAgent(meta, isAlive)) continue; // recordOrphanEnd rather than a bare status flip (#1359): the worktree's log gains the // `end` event first, so the archive below copies a stream that actually ends. await recordOrphanEnd(fs, worktreeDir, meta); await archiveWorktreeAgent(entry.path, cwd, fs).catch(() => undefined); fixed++; } return fixed; } /** * Whether `pid` is a live process on this host. `process.kill(pid, 0)` sends no signal but * throws `ESRCH` once the process is gone; `EPERM` means it exists under another user (still * alive). A pid on a *different* host is unknowable here, so callers guard on {@link AgentMeta.host} * before trusting a result. A recycled pid (another process reusing a dead agent's number) reads as * alive — an accepted, vanishingly rare miss on a single dev box. */ export function isPidAlive(pid) { try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; } } /** * The live (in-progress) run's meta snapshot from `.the-framework/agent.json`, or * `undefined` when none/unreadable. Unlike {@link listAgents} (which reads the * archived `agents/` copies written on close), this is the agent the daemon is * tailing right now — so the dashboard can list it with a `running` status * before it finishes. Missing or torn file yields `undefined`, never throws. * * Self-heals a stale agent on read (#716): if the meta says `running` but its owning process died * without writing `end` (a crash, `kill -9`, or the machine sleeping), nothing is left to consume * `control.jsonl` — so Stop is a no-op and the row is stuck. When the owning pid is gone on this * host, flip it to `stopped` and archive it, so the dashboard clears the row on the next poll * instead of only after a daemon restart's boot-time {@link reconcileOrphanedAgents}. An agent whose * meta predates this field (no `pid`) is left untouched — the boot reconcile still catches it. */ export async function readLiveMeta(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { const dir = join(cwd, FRAMEWORK_DIR); const meta = await readMetaFile(fs, join(dir, META_FILE)); if (!meta) return undefined; // Only a provably dead owner heals here — 'unknown' (no pid / another host) is left alone. if (ownerLiveness(meta, isAlive) === 'dead') return stopAndArchiveLive(fs, dir, meta); return meta; } /** * Every live agent of a project (#738): the list variant of {@link readLiveMeta}. * * An agent started from the dashboard gets its own worktree (#736) and writes its `agent.json` * inside it, so the project path alone no longer sees any of them. This looks in both places: * each `.the-framework/branches/*` checkout, and the repo root itself, which is where a * project that cannot be given a worktree (not a git repo) still runs and where every agent * from before #736 lives. * * Each candidate goes through {@link readLiveMeta}, so a stale agent self-heals exactly as it * did. Newest first, by id. Never throws: an unreadable worktree is skipped. */ export async function readLiveMetas(cwd, fs = nodeStoreFs(), isAlive = isPidAlive) { // The checkouts under `branches/`: run-branch-named dirs, never the rename links beside them. const candidates = [cwd, ...(await worktreeDirEntries(cwd, fs)).map(entry => entry.path)]; const agents = []; for (const candidate of candidates) { const meta = await readLiveMeta(candidate, fs, isAlive).catch(() => undefined); if (meta) agents.push({ ...meta, cwd: candidate }); } return agents.sort(byIdDesc); } /** * Read one archived agent's event log for replay. Returns `undefined` for an * unknown or unsafe id; a torn trailing line is dropped (same rule as the live * {@link AgentStore.loadEvents}). */ export async function loadAgentEvents(cwd, id, fs = nodeStoreFs()) { if (!isSafeAgentId(id)) return undefined; const archive = await findArchive(fs, cwd, id); if (!archive || !(await fs.exists(archive.events))) return undefined; return parseEventLog(await fs.read(archive.events)); } /** A {@link StoreFs} backed by `node:fs/promises`. See {@link nodeFs}. */ export function nodeStoreFs() { // Destructured rather than returned whole: the narrow interface is the contract, // so the object should not carry methods the store was never handed. const { read, write, append, exists, mkdir, readdir, rename } = nodeFs(); return { read, write, append, exists, mkdir, readdir, rename }; } /** * A project's runs: the live ones prepended to the archived history, newest-first. Forgiving — * a side that cannot be read simply contributes nothing. * * Live wins over archived (#768). The dedup used to drop the live copy, which was right while * "archived" meant "finished for good": an agent was only ever copied into `agents/` on its way out. * Continuing an agent (#762) breaks that — the agent has an archived copy from its first leg AND is * live again — and keeping the archive showed a running agent as finished. * * This composition, not its two halves, is what every caller actually wants; the store exporting * only the halves is why three separate modules each grew their own copy of it. */ export async function readAllAgents(cwd, fs = nodeStoreFs()) { const [archived, live] = await Promise.all([ listAgents(cwd, fs).catch(() => []), readLiveMetas(cwd, fs).catch(() => []), ]); return [...live, ...archived.filter(agent => !live.some(l => l.id === agent.id))]; } /** * One agent's meta by id, live copy winning over archived — {@link readAllAgents}'s rule for a * single row. The find-by-id shape the RPCs kept privately rebuilding, for the same reason * the list shape did: the store exported only the halves. */ export async function findAgent(cwd, agentId, fs = nodeStoreFs()) { return (await readAllAgents(cwd, fs)).find(agent => agent.id === agentId); } /** * Read a checkout's live event log (`.the-framework/events.jsonl`). Missing or unreadable * yields `[]`, and a torn trailing line is dropped — the same rule as * {@link AgentStore.loadEvents}, exported so a reader outside the store (the Discord bot's gate * lookup) cannot keep a second parser with a drifted torn-line policy. */ export async function readEventLog(cwd, fs = nodeStoreFs()) { const path = join(cwd, FRAMEWORK_DIR, EVENTS_FILE); try { if (!(await fs.exists(path))) return []; return parseEventLog(await fs.read(path)); } catch { return []; } } /** * Patch an archived run's record with a fact discovered once the agent's process is gone (E6, * #1601): the pull request opened for its work, or the branch a cloud session's work landed * on. There is no event stream left to carry it, and every surface reads the record, so this * one write is what turns a "nothing committed" row into its real branch and PR. * * A plain file write: the archive lives on the data branch's checkout, and a fact written there * is only durable once committed — {@link patchArchivedAgentOnDataBranch} is the funneled form * every caller outside a test uses. */ export async function patchArchivedAgent(cwd, agentId, patch, fs = nodeStoreFs()) { if (!isSafeAgentId(agentId)) return false; try { const archive = await findArchive(fs, cwd, agentId); if (!archive) return false; const meta = await readMetaFile(fs, archive.meta); if (!meta) return false; await fs.write(archive.meta, JSON.stringify({ ...meta, ...patch })); return true; } catch { return false; } } //# sourceMappingURL=agent-store.js.map