@sema-agent/core
Version:
Stateless, task-oriented AI agent core
284 lines (283 loc) • 16.3 kB
JavaScript
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;
}