UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

201 lines (200 loc) • 8.5 kB
import { join } from "node:path"; import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgroundAgentReapOptions, queryBackgroundAgents, } from "../../core/background-agent-store.js"; import { SharedLedgerTable } from "./shared-ledger.js"; import { guardCorruptSink } from "./corrupt-read-sink.js"; import { assertAdoptionBootGate } from "./adoption/marker.js"; const isAgentRow = (x) => x !== null && typeof x === "object" && typeof x.handle === "string" && typeof x.scope === "string"; const agentLedgers = new SharedLedgerTable({ label: "background-agent ledger", keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope), apply: (rows, ev) => { if (ev.t === "delete") { rows.delete(ev.k); return; } rows.set(ev.k, ev.r); }, judgeRow: isAgentRow, judgeEvent: (x) => { const ev = x; if (typeof ev.k !== "string") return false; return ev.t === "delete" ? true : isAgentRow(ev.r); }, }); export class FileBackgroundAgentStore { fsyncEnabled; compactEvery; ledger; closed = false; get rows() { return this.ledger.rows; } constructor(root, opts = {}) { assertAdoptionBootGate(root, "FileBackgroundAgentStore"); this.fsyncEnabled = opts.fsync !== false; this.compactEvery = opts.compactEvery ?? 1000; const dir = join(root, "background-agents"); this.ledger = agentLedgers.acquire({ dir, tmpDir: join(root, "tmp"), ...(opts.onCorruptRead !== undefined ? { onCorruptRead: guardCorruptSink(opts.onCorruptRead) } : {}), ledgerPath: join(dir, "agents.jsonl"), snapshotPath: join(dir, "agents.snapshot.jsonl"), }); } static key(handle, scope) { return `${scope}\u0000${handle}`; } withLock(key, fn) { return this.ledger.withLock(key, fn); } commit(ev) { this.ledger.commit(ev, this.fsyncEnabled, this.compactEvery); } 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 = FileBackgroundAgentStore.key(record.handle, record.scope); return this.withLock(k, () => { 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.commit({ t: "put", k, r: stored }); }); } async get(handle, scope) { const row = this.rows.get(FileBackgroundAgentStore.key(handle, scope)); return row ? structuredClone(row) : null; } async update(handle, scope, record, expect) { const k = FileBackgroundAgentStore.key(handle, scope); return this.withLock(k, () => { 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.commit({ t: "update", k, r: stored }); return true; }); } async updateIf(handle, scope, record, expect) { const k = FileBackgroundAgentStore.key(handle, scope); return this.withLock(k, async () => { 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; const stored = structuredClone(record); stored.handle = handle; stored.scope = scope; stored.rev = live.rev + 1; this.commit({ t: "update", k, r: stored }); return true; }); } 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 listScopes() { return [...new Set([...this.rows.values()].map((r) => r.scope))].sort(); } async delete(handle, scope, expect) { const k = FileBackgroundAgentStore.key(handle, scope); return this.withLock(k, () => { const live = this.rows.get(k); if (!live) return false; if (expect !== undefined && live.rev !== expect.rev) return false; this.commit({ t: "delete", k }); return true; }); } async reap(scope, now, opts) { assertBackgroundAgentReapOptions(opts); if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined)) return 0; const changed = new Set(); if (opts.staleRunningMaxAgeMs !== undefined) { const staleCandidates = [...this.rows.entries()] .filter(([, r]) => r.scope === scope && r.status === "running" && now - r.updatedAt > opts.staleRunningMaxAgeMs) .map(([k]) => k); for (const k of staleCandidates) { const flipped = await this.withLock(k, () => { const live = this.rows.get(k); if (!live || live.status !== "running" || now - live.updatedAt <= opts.staleRunningMaxAgeMs) return false; const next = structuredClone(live); next.status = "failed"; next.stoppedBy = "system"; next.summary = next.summary ?? STALE_RUNNING_REAP_ATTRIBUTION; next.error = next.error ?? STALE_RUNNING_REAP_ATTRIBUTION; next.settledAt = now; next.updatedAt = now; next.rev = live.rev + 1; this.commit({ t: "update", k, r: next }); return true; }); if (flipped) changed.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 Map(); if (opts.maxAgeMs !== undefined) { for (const [k, r] of terminal) { if ((r.settledAt ?? r.spawnedAt) < now - opts.maxAgeMs) doomed.set(k, { byAge: true }); } } if (opts.keep !== undefined) { for (const [k] of terminal.slice(Math.max(0, opts.keep))) { if (!doomed.has(k)) doomed.set(k, { byAge: false }); } } for (const [k, why] of doomed) { const deleted = await this.withLock(k, () => { const live = this.rows.get(k); if (!live || live.scope !== scope || live.status === "running" || live.status === "parked") return false; if (why.byAge && opts.maxAgeMs !== undefined && (live.settledAt ?? live.spawnedAt) >= now - opts.maxAgeMs) return false; this.commit({ t: "delete", k }); return true; }); if (deleted) changed.add(k); } return changed.size; } compactNow() { this.ledger.compact(); } get size() { return this.rows.size; } close() { if (this.closed) return; this.closed = true; this.ledger.release(); } }