UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

284 lines (283 loc) • 16.3 kB
import { NodeExecutionEnv } from "../../engine/execution-env/node-execution-env.js"; import { isRemoteExecutionEnv } from "../remote-env.js"; import { resolveTrackKey } from "../file-history-store.js"; export async function prepareFileHistory(input) { const { deps, spec, request, internals, executionEnv, taskRootFinal, handsEnabled, sessionId, session, abortController } = input; const rewindNotes = []; const { onTrackFailure, captureOff, declaredOff, fileHistoryStore, captureStore, rewindTarget, rewindBefore } = request; if (spec.rewindFiles === true) { try { deps.onError?.(new Error(`TaskSpec.rewindFiles (capture sense) is retired and was ignored: per-edited-file first-touch tracking is on whenever RunnerDeps.fileHistoryStore is wired and neither RunnerDeps.fileHistoryCapture nor TaskSpec.fileHistory turns it off, so a per-turn capture request has nothing left to ask for. Drop the field; the restore sense moved to TaskSpec.restoreFiles.`), { phase: "config", sessionId }); } catch { } } if (spec.resumeAt !== undefined && spec.restoreFiles !== true) { rewindNotes.push({ code: "conversation_only", message: `the conversation was branched at entry "${spec.resumeAt}" but the working tree was NOT rewound — this task set resumeAt without restoreFiles, so files remain at their current state`, }); } const fileHistoryEnabled = captureStore !== undefined && handsEnabled; const livenessArm = fileHistoryStore === undefined ? "file-history-no-store" : "file-history-no-env"; if (!fileHistoryEnabled && !declaredOff && !captureOff && (handsEnabled || fileHistoryStore !== undefined) && deps.onError !== undefined && !toldAlready(deps.onError, livenessArm)) { const reason = livenessArm === "file-history-no-store" ? `this deployment wired no RunnerDeps.fileHistoryStore, so NOTHING is recorded: no first-touch snapshot before an edit, no turn boundary, and a later restoreFiles / rewindFilesTo has nothing to restore from and will refuse (rewind.store_unconfigured). Supply a store, or accept an unrecoverable working tree knowingly (fileHistoryStore: "disabled" says so and silences this line)` : `RunnerDeps.fileHistoryStore is wired but this run mounts no filesystem-capable ExecutionEnv, so the store is INERT: there is no working tree to snapshot and nothing will ever be written to it. Wire RunnerDeps.executionEnv, or drop the store from this deployment shape`; try { deps.onError(new Error(`file history is not live for this run — ${reason}.`), { phase: "config", sessionId, classification: livenessArm }); } catch { } } const { historyRoot, historyScope, historyFs } = await resolveFileHistoryCoordinates(fileHistoryStore !== undefined && handsEnabled, executionEnv, taskRootFinal, internals?.fileHistoryLineage, sessionId); if (captureStore !== undefined && internals?.fileHistoryLineage === undefined) await adoptForkedFileHistory(captureStore, session, sessionId, deps.onError); if (rewindTarget !== undefined && historyScope !== sessionId) throw childScopeRewindRefusal(historyScope, rewindTarget); if (fileHistoryStore !== undefined && !handsEnabled) { if (rewindTarget !== undefined) { rewindNotes.push({ code: "files_env_unsupported", message: "the file side of rewind was inert: this deployment mounts no filesystem-capable ExecutionEnv, so no file history was recorded or restored", }); } } let restoredFilePaths = []; if (rewindTarget !== undefined && fileHistoryStore !== undefined && handsEnabled) { const historyStore = fileHistoryStore; if (rewindBefore && !(await historyStore.canRestore(sessionId, rewindTarget))) throw beforeWithoutBoundaryRefusal(rewindTarget, sessionId); const restoreRoot = historyRoot; const restoreSignal = spec.signal ? AbortSignal.any([abortController.signal, spec.signal]) : abortController.signal; const restored = await historyStore.restore(sessionId, rewindTarget, executionEnv, restoreRoot, restoreSignal); const ledgerLine = (r) => `applied ${r.filesChanged.length}, identical ${r.identicalSkipped.length}, refused ${r.refused.length}${r.refused.length > 0 ? ` (${r.refused.map((f) => `${f.path}: ${f.reason}`).join("; ")})` : ""}, failed ${r.failed.length}${r.failed.length > 0 ? ` (${r.failed.map((f) => `${f.path}: ${f.reason}`).join("; ")})` : ""}`; if (restored.outcome === "failed") { if (restored.error?.code === "not_found") { if (rewindBefore) throw beforeWithoutBoundaryRefusal(rewindTarget, sessionId); const e = new Error(`rewind-files: no file-history boundary exists for entry "${rewindTarget}" on session "${sessionId}" — the working tree was NOT rewound. ${rewindRefusalTail()}`); e.code = "rewind_snapshot.unresolvable"; throw e; } const e = new Error(`rewind-files restore failed (${restored.error?.code ?? "restore_failed"}): ${restored.error?.message ?? "unknown"} — ledger: ${ledgerLine(restored)}`); e.code = "rewind.restore_failed"; throw e; } if (restored.outcome === "partial") { if (spec.acceptPartialRestore === true) { rewindNotes.push({ code: "restore_partial", message: `file restore to entry "${rewindTarget}" was PARTIAL and was tolerated by acceptPartialRestore — ledger: ${ledgerLine(restored)}. Re-running the same restore converges (per-file idempotent).`, }); } else { const e = new Error(`rewind-files: restore to entry "${rewindTarget}" was PARTIAL — ledger: ${ledgerLine(restored)}. The task is refused rather than run on a mixed-state tree (set acceptPartialRestore: true to tolerate and disclose instead); re-running the same restore converges (per-file idempotent).`); e.code = "rewind.restore_failed"; throw e; } } restoredFilePaths = restoredAbsPathsOf(restored, restoreRoot); } const trackFileEdit = fileHistoryEnabled ? async (req) => { const r = await fileHistoryStore.trackEdit(historyScope, req.key, executionEnv, historyRoot, req.signal); if (r.ok) { if (!r.minted) return { ok: true }; return { ok: true, annul: async (proof) => { const a = await fileHistoryStore.annulTrack(historyScope, req.key, historyRoot, proof === "verify" ? { env: executionEnv, ...(req.signal !== undefined ? { signal: req.signal } : {}) } : undefined); if (a.ok) return; try { deps.onError?.(new Error(`file-history could not retract the first-touch record for "${req.key}" after its edit failed to land (${a.error.code}: ${a.error.message}) — the path stays tracked, so a rewind to a boundary below it may converge bytes this engine never wrote`), { phase: "rewind", sessionId }); } catch { } }, }; } if (onTrackFailure === "proceed-unprotected") { try { deps.onError?.(new Error(`file-history first-touch backup for "${req.key}" failed (${r.error.code}: ${r.error.message}) — proceeding UNPROTECTED per RunnerDeps.onTrackFailure: the pristine promise for this path is void, and only in this process's memory (no durable trace exists, so after a process restart the next edit can still mint already-modified bytes as pristine v1)`), { phase: "rewind", sessionId }); } catch { } return { ok: true }; } return { ok: false, refusal: `Error (${req.tool}): the edit was refused because its first-touch file-history backup could not be persisted (${r.error.code}: ${r.error.message}). Proceeding would leave "${req.path}" with no durable pristine record — after the history store recovers, a retried edit would mint already-modified bytes as the pristine baseline. Retry when the file-history store is healthy.`, }; } : undefined; const fileHistoryBoundary = fileHistoryEnabled && historyScope === sessionId ? createFileHistoryBoundarySeat({ store: fileHistoryStore, sessionId, env: executionEnv, root: historyRoot, onError: deps.onError }) : undefined; const fileHistoryFact = fileHistoryEnabled ? "on" : "disabled"; return { trackFileEdit, fileHistoryBoundary, restoredFilePaths, rewindNotes, fileHistoryEnabled, fileHistoryFact, historyScope, historyRoot, historyFs }; } function restoredAbsPathsOf(restored, root) { const out = []; for (const key of [...restored.filesChanged, ...restored.failed.map((f) => f.path)]) { const abs = resolveTrackKey(root, key); if (abs.ok) out.push(abs.abs); } return out; } function rewindRefusalTail() { return `Boundaries are keyed by a turn's INITIAL entry and exist only for turns that recorded history (a RunnerDeps.fileHistoryStore wired, capture on, and the run's file history not disabled); a boundary from the retired whole-tree snapshot era is not restorable by this seat, and reaped boundaries have none.`; } function beforeWithoutBoundaryRefusal(target, sessionId) { const e = new Error(`rewind-files: entry "${target}" has no file-history boundary on session "${sessionId}" — a before-restore needs the target's own boundary and never crosses into an earlier turn, so the working tree was NOT rewound. ${rewindRefusalTail()}`); e.code = "rewind_snapshot.unresolvable"; return e; } const FILE_HISTORY_BOUNDARY_TIMEOUT_MS = 30_000; const FILE_HISTORY_LIVENESS_MEMO = new WeakMap(); function toldAlready(sink, arm) { const heard = FILE_HISTORY_LIVENESS_MEMO.get(sink) ?? new Set(); FILE_HISTORY_LIVENESS_MEMO.set(sink, heard); if (heard.has(arm)) return true; heard.add(arm); return false; } const FILE_HISTORY_CARRY_MEMO = new WeakMap(); async function adoptForkedFileHistory(store, session, sessionId, onError) { const memo = FILE_HISTORY_CARRY_MEMO.get(store) ?? { settled: new Set(), disclosed: new Set(), inFlight: new Map() }; FILE_HISTORY_CARRY_MEMO.set(store, memo); const discloseOnce = (err) => { if (memo.disclosed.has(sessionId)) return; memo.disclosed.add(sessionId); onError?.(err, { phase: "rewind", sessionId }); }; try { if (memo.settled.has(sessionId)) return; const forkedFrom = (await session.getMetadata()).forkedFrom; if (forkedFrom === undefined || forkedFrom === sessionId) return; const shared = memo.inFlight.get(sessionId); if (shared !== undefined) return await shared; if (memo.settled.has(sessionId)) return; const attempt = store.adoptScope(forkedFrom, sessionId).then((carried) => { if (carried.ok || carried.error.code === "conflict") { memo.settled.add(sessionId); return; } discloseOnce(new Error(`file-history fork carry from session "${forkedFrom}" failed (${carried.error.code}): ${carried.error.message} — this forked session starts with an EMPTY rewind history, so boundaries recorded before the fork are not restorable from it (the run itself proceeds; the carry is retried on this session's next turn while its scope is still empty)`)); }, (err) => discloseOnce(err)); memo.inFlight.set(sessionId, attempt); try { await attempt; } finally { memo.inFlight.delete(sessionId); } } catch (err) { try { discloseOnce(err); } catch { } } } function createFileHistoryBoundarySeat(opts) { let inFlight; let begun = false; return { begin(entryId) { if (begun) return; begun = true; const ac = new AbortController(); let timer; const timeout = new Promise((resolve) => { timer = setTimeout(() => { ac.abort(); resolve({ ok: false }); }, FILE_HISTORY_BOUNDARY_TIMEOUT_MS); }); let work; try { work = opts.store.snapshot(opts.sessionId, entryId, opts.env, opts.root, ac.signal); } catch (err) { work = Promise.resolve({ ok: false, error: { code: "snapshot_failed", message: `history store threw synchronously: ${err.message}` } }); } work.catch(() => { }); inFlight = Promise.race([ work.then((r) => (r.ok ? undefined : `boundary capture failed (${r.error.code}): ${r.error.message}`)), timeout.then(() => `boundary capture timed out after ${FILE_HISTORY_BOUNDARY_TIMEOUT_MS / 1000}s (fenced — the attempt can never publish)`), ]) .then((failure) => { if (failure !== undefined) { try { opts.onError?.(new Error(`file-history ${failure} — this turn has no boundary and cannot be rewound to (the turn itself proceeds)`), { phase: "rewind", sessionId: opts.sessionId }); } catch { } } }) .catch((err) => { try { opts.onError?.(err, { phase: "rewind", sessionId: opts.sessionId }); } catch { } }) .finally(() => clearTimeout(timer)); }, async settle() { if (inFlight !== undefined) await inFlight; }, }; } async function resolveFileHistoryCoordinates(enabled, env, taskRoot, lineage, sessionId) { if (!enabled) return { historyRoot: taskRoot, historyScope: sessionId, historyFs: "" }; const canonRoot = await env.canonicalPath(taskRoot); const historyRoot = canonRoot.ok ? canonRoot.value : taskRoot; const historyFs = fileHistoryFilesystemIdentity(env); return { historyRoot, historyScope: resolveFileHistoryScope(lineage, historyRoot, historyFs, sessionId), historyFs }; } export function resolveFileHistoryScope(lineage, historyRoot, historyFs, sessionId) { if (lineage === undefined || lineage.scope === "" || lineage.root !== historyRoot || lineage.fs !== historyFs) return sessionId; return lineage.scope; } const envFilesystemTokens = new WeakMap(); let envFilesystemSeq = 0; export function fileHistoryFilesystemIdentity(env) { if (isRemoteExecutionEnv(env)) { try { const h = env.workspaceHandle(); return JSON.stringify(["remote", h.provider, h.sandboxId, h.deviceId !== undefined && h.deviceId !== "" ? h.deviceId : null]); } catch { } } else if (env.hostLocalPaths === true || (env.hostLocalPaths === undefined && env instanceof NodeExecutionEnv)) { return "host"; } let token = envFilesystemTokens.get(env); if (token === undefined) { token = `instance:${++envFilesystemSeq}`; envFilesystemTokens.set(env, token); } return token; } function childScopeRewindRefusal(rootScope, target) { const e = new Error(`rewind-files: this run is a delegated child recording its edits into the ROOT session "${rootScope}"'s file history, so restoring files to entry "${target}" is the root session's action (rewindFilesTo / resumeAt+restoreFiles on that session), not the child's — the files were NOT rewound`); e.code = "rewind.child_scope_unsupported"; return e; }