@sema-agent/core
Version:
Stateless, task-oriented AI agent core
318 lines (317 loc) • 13.5 kB
JavaScript
import { uuidv7 } from "../internal/harness.js";
import { assertRetentionPolicy } from "./retention-policy.js";
export const REVIVED_ROW_CLEARED_FIELDS = Object.freeze([
"settledAt",
"stoppedBy",
"completionId",
"finalOutput",
"finalOutputFull",
"error",
"errorCode",
"errorRetryable",
"errorKind",
"errorRetryAfterMs",
"resultIsPartial",
"summary",
"recentSteps",
"editedFiles",
"usage",
]);
export function clearRevivedRowTerminalPayload(record) {
for (const field of REVIVED_ROW_CLEARED_FIELDS)
delete record[field];
}
const transcriptIntegrityAnnounced = new Set();
export function announceTranscriptIntegrityGapOnce(handle, scope, sink) {
if (sink === undefined)
return;
const key = `${scope ?? ""}\u0000${handle}`;
if (transcriptIntegrityAnnounced.has(key))
return;
transcriptIntegrityAnnounced.add(key);
try {
sink(handle, scope);
}
catch {
}
}
export class BackgroundAgentStoreError extends Error {
code;
constructor(code, message) {
super(message);
this.code = code;
this.name = "BackgroundAgentStoreError";
}
}
export function canAccessAgentRecord(record, access) {
if (access.scope === undefined || record.scope !== access.scope)
return false;
if (access.owner !== undefined && access.owner === record.owner)
return true;
if (access.sessionId !== undefined) {
const sessionAnchor = record.sessionScoped ? record.owner : record.parentSessionId;
if (sessionAnchor !== undefined && access.sessionId === sessionAnchor)
return true;
if (record.rootSessionId !== undefined && access.sessionId === record.rootSessionId)
return true;
}
return false;
}
export const STALE_RUNNING_REAP_ATTRIBUTION = "the host process was interrupted while this agent was running — its in-process state was lost. Check its worktree / output file for partial work before assuming the task landed (stale running row reaped).";
export function assertBackgroundAgentReapOptions(opts) {
assertRetentionPolicy("BackgroundAgentStore.reap", opts);
}
export async function reconcileParkedAgents(stores, scope, now, opts) {
const out = { failed: 0, rolledBack: 0 };
const excluded = (row) => (opts?.excludeHandles?.has(row.handle) ?? false) ||
(opts?.excludeWriterId !== undefined && row.writerId === opts.excludeWriterId);
const failRow = async (row, why) => {
const next = structuredClone(row);
next.status = "failed";
next.error = why;
next.stoppedBy = "system";
next.settledAt = now;
next.updatedAt = now;
next.completionId ??= uuidv7();
delete next.parkedCheckpointToken;
delete next.parkClaimId;
delete next.parkedAt;
return stores.agentStore.update(row.handle, scope, next, { rev: row.rev });
};
const checkpointState = async (token) => {
try {
const cp = await stores.checkpointStore.get(token);
if (cp === null)
return "missing";
return cp.status;
}
catch {
return "unreadable";
}
};
for (const summary of await stores.agentStore.listByScope(scope, { status: "parked" })) {
const row = await stores.agentStore.get(summary.handle, scope);
if (!row || row.status !== "parked")
continue;
if (excluded(row))
continue;
const token = row.parkedCheckpointToken;
if (token === undefined) {
if (await failRow(row, "parked row lost its approval-checkpoint binding (reconciled)"))
out.failed++;
continue;
}
const state = await checkpointState(token);
if (state === "expired" || state === "missing") {
if (await failRow(row, `approval checkpoint ${state} while parked (reconciled)`))
out.failed++;
}
}
const staleMs = opts?.staleClaimMaxAgeMs;
if (staleMs !== undefined) {
const staleClaims = [];
for (const status of ["parked", "running"]) {
for (const summary of await stores.agentStore.listByScope(scope, { status })) {
if (now - summary.updatedAt <= staleMs)
continue;
const row = await stores.agentStore.get(summary.handle, scope);
if (!row || row.status !== status)
continue;
if (row.parkClaimId === undefined && !(status === "parked" && row.parkedCheckpointToken !== undefined))
continue;
if (status === "running" && (row.parkClaimId === undefined || row.parkedCheckpointToken === undefined))
continue;
if (excluded(row))
continue;
if (now - row.updatedAt <= staleMs)
continue;
staleClaims.push(row);
}
}
for (const row of staleClaims) {
const token = row.parkedCheckpointToken;
const state = token === undefined ? "missing" : await checkpointState(token);
if (row.status === "parked") {
if (row.parkClaimId === undefined) {
if (state === "resolved") {
if (await failRow(row, "approval consumed but the park flip was lost — outcome unknown (reconciled)"))
out.failed++;
}
continue;
}
if (state === "pending") {
const next = structuredClone(row);
next.writerEpoch = (row.writerEpoch ?? 0) + 1;
next.updatedAt = now;
delete next.parkClaimId;
if (await stores.agentStore.updateIf(row.handle, scope, next, { rev: row.rev, status: "parked", parkClaimId: row.parkClaimId }))
out.rolledBack++;
}
else if (state === "resolved") {
if (await failRow(row, "approval consumed but the resume claimer died before finalize — outcome unknown (reconciled)"))
out.failed++;
}
}
else {
if (state === "pending") {
const next = structuredClone(row);
next.writerEpoch = (row.writerEpoch ?? 0) + 1;
next.status = "parked";
next.updatedAt = now;
delete next.parkClaimId;
if (await stores.agentStore.updateIf(row.handle, scope, next, { rev: row.rev, status: "running", parkClaimId: row.parkClaimId }))
out.rolledBack++;
}
else if (state === "expired" || state === "missing") {
if (await failRow(row, `approval checkpoint ${state} under a stale resume claim (reconciled)`))
out.failed++;
}
else if (state === "resolved") {
if (await failRow(row, "approval consumed but the resume claimer died before finalize — outcome unknown (reconciled)"))
out.failed++;
}
}
}
}
return out;
}
function toSummary(r) {
return {
handle: r.handle,
owner: r.owner,
sessionScoped: r.sessionScoped,
...(r.sessionId !== undefined ? { sessionId: r.sessionId } : {}),
...(r.parentSessionId !== undefined ? { parentSessionId: r.parentSessionId } : {}),
...(r.rootSessionId !== undefined ? { rootSessionId: r.rootSessionId } : {}),
...(r.name !== undefined ? { name: r.name } : {}),
...(r.agentType !== undefined ? { agentType: r.agentType } : {}),
status: r.status,
spawnedAt: r.spawnedAt,
updatedAt: r.updatedAt,
...(r.settledAt !== undefined ? { settledAt: r.settledAt } : {}),
...(r.usage !== undefined ? { usage: structuredClone(r.usage) } : {}),
};
}
export function queryBackgroundAgents(rows, query) {
const out = [];
for (const r of rows) {
if (r.scope !== query.scope)
continue;
if (query.sessionId !== undefined) {
const anchor = r.sessionScoped ? r.owner : r.parentSessionId;
if (anchor !== query.sessionId && r.rootSessionId !== query.sessionId)
continue;
}
if (query.status !== undefined && r.status !== query.status)
continue;
out.push(toSummary(r));
}
out.sort((a, b) => b.spawnedAt - a.spawnedAt);
return query.limit !== undefined ? out.slice(0, Math.max(0, query.limit)) : out;
}
export class InMemoryBackgroundAgentStore {
rows = new Map();
key(handle, scope) {
return `${scope}\u0000${handle}`;
}
async put(record) {
if (record.scope === undefined || record.scope === "") {
throw new Error("BackgroundAgentStore.put: refusing a row without a scope (world-readable through the fallback)");
}
const k = this.key(record.handle, record.scope);
if (this.rows.has(k)) {
throw new BackgroundAgentStoreError("agent_record.already_exists", "agent record already exists");
}
const stored = structuredClone(record);
stored.rev = record.rev ?? 0;
this.rows.set(k, stored);
}
async get(handle, scope) {
const row = this.rows.get(this.key(handle, scope));
return row ? structuredClone(row) : null;
}
async update(handle, scope, record, expect) {
const k = this.key(handle, scope);
const live = this.rows.get(k);
if (!live || live.scope !== scope || live.rev !== expect.rev)
return false;
const stored = structuredClone(record);
stored.handle = handle;
stored.scope = scope;
stored.rev = live.rev + 1;
this.rows.set(k, stored);
return true;
}
async updateIf(handle, scope, record, expect) {
const k = this.key(handle, scope);
const live = this.rows.get(k);
if (!live || live.scope !== scope || live.rev !== expect.rev)
return false;
if (expect.status !== undefined && live.status !== expect.status)
return false;
if (expect.parkedCheckpointToken !== undefined && (expect.parkedCheckpointToken === null ? live.parkedCheckpointToken !== undefined : live.parkedCheckpointToken !== expect.parkedCheckpointToken))
return false;
if (expect.parkClaimId !== undefined && (expect.parkClaimId === null ? live.parkClaimId !== undefined : live.parkClaimId !== expect.parkClaimId))
return false;
return this.update(handle, scope, record, { rev: expect.rev });
}
async listBySession(scope, sessionId, opts) {
return queryBackgroundAgents(this.rows.values(), { scope, sessionId, status: opts?.status, limit: opts?.limit });
}
async listByScope(scope, opts) {
return queryBackgroundAgents(this.rows.values(), { scope, status: opts?.status, limit: opts?.limit });
}
async delete(handle, scope, expect) {
const k = this.key(handle, scope);
const live = this.rows.get(k);
if (!live)
return false;
if (expect !== undefined && live.rev !== expect.rev)
return false;
return this.rows.delete(k);
}
async listScopes() {
return [...new Set([...this.rows.values()].map((r) => r.scope))].sort();
}
async reap(scope, now, opts) {
assertBackgroundAgentReapOptions(opts);
if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined))
return 0;
const flippedKeys = new Set();
if (opts.staleRunningMaxAgeMs !== undefined) {
for (const [k, r] of this.rows) {
if (r.scope !== scope || r.status !== "running")
continue;
if (now - r.updatedAt <= opts.staleRunningMaxAgeMs)
continue;
const flipped = structuredClone(r);
flipped.status = "failed";
flipped.stoppedBy = "system";
flipped.summary = flipped.summary ?? STALE_RUNNING_REAP_ATTRIBUTION;
flipped.error = flipped.error ?? STALE_RUNNING_REAP_ATTRIBUTION;
flipped.settledAt = now;
flipped.updatedAt = now;
flipped.rev = r.rev + 1;
this.rows.set(k, flipped);
flippedKeys.add(k);
}
}
const terminal = [...this.rows.entries()]
.filter(([, r]) => r.scope === scope && r.status !== "running" && r.status !== "parked")
.sort(([, a], [, b]) => b.spawnedAt - a.spawnedAt);
const doomed = new Set();
if (opts.maxAgeMs !== undefined) {
for (const [k, r] of terminal) {
if ((r.settledAt ?? r.spawnedAt) < now - opts.maxAgeMs)
doomed.add(k);
}
}
if (opts.keep !== undefined) {
for (const [k] of terminal.slice(Math.max(0, opts.keep)))
doomed.add(k);
}
for (const k of doomed)
this.rows.delete(k);
return new Set([...flippedKeys, ...doomed]).size;
}
}