UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

318 lines (317 loc) • 13.5 kB
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; } }