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