Files
pikasTech-agentrun/src/mgr/store.ts
T
2026-07-12 10:45:12 +02:00

1815 lines
96 KiB
TypeScript

import type { BackendProfile, BackendTurnResult, CancelRequestRecord, CancelStage, CancelTargetKind, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, KafkaEventOutboxRecord, ListGcExpiredSessionsInput, QueueAttemptListResult, QueueAttemptRecord, QueueAttemptRef, QueueCommanderSnapshot, QueueReadCursorRecord, QueueRetryActivation, QueueRetryReservation, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, QueueTaskSummary, RunEvent, RunnerDispatchCompletion, RunnerDispatchIntentRecord, RunnerJobRecord, RunnerRecord, RunRecord, SessionEventPage, SessionListResult, SessionListState, SessionReadCursorRecord, SessionRecord, SessionRef, SessionStoragePatch, SessionSummary, TerminalStatus, UpsertSessionInput } from "../common/types.js";
import { AgentRunError } from "../common/errors.js";
import { newId, nowIso, stableHash } from "../common/validation.js";
import { redactJson } from "../common/redaction.js";
import { backendCapabilities } from "../common/backend-profiles.js";
import { normalizeRunEventPayload, requireEventType, requireExternallyAppendableEventType, userMessagePayloadForCommand } from "../common/events.js";
import { buildKafkaEventOutboxRecord } from "./event-outbox.js";
import { assertRunnerDispatchReplay, attachRunnerDispatchIntent, newRunnerDispatchIntent } from "./runner-dispatch-intent.js";
export type MaybePromise<T> = T | Promise<T>;
export interface StoreHealth extends JsonRecord {
adapter: "memory-self-test" | "postgres";
ready: boolean;
reachable: boolean;
migrationReady: boolean;
migrationId: string | null;
failureKind: FailureKind | null;
message: string | null;
credentialValuesPrinted: false;
}
export interface EventOutboxConfig {
enabled: boolean;
topic: string | null;
source: string | null;
}
export interface DurableQueueClaim {
id: string;
leaseOwner: string | null;
attemptCount: number;
}
export interface RunnerRetentionFenceFacts {
run: RunRecord;
command: CommandRecord;
commands: CommandRecord[];
events: RunEvent[];
}
export interface RunnerRetentionFenceInput {
runId: string;
commandId: string;
terminalizeStalePending: boolean;
releaseRunnerClaim: boolean;
reason: string;
}
export interface SessionTurnAdmissionInput {
sessionId: string;
run: CreateRunInput;
command: CreateCommandInput;
}
export interface SessionTurnAdmission extends JsonRecord {
run: RunRecord;
command: CommandRecord;
disposition: "created" | "replayed" | "recovered";
recoveredPriorPartialWrite: boolean;
partialWrite: false;
}
export interface AgentRunStore {
health(): MaybePromise<StoreHealth>;
createRun(input: CreateRunInput, identity?: CreateRunIdentity): MaybePromise<RunRecord>;
getRun(runId: string): MaybePromise<RunRecord>;
listEvents(runId: string, afterSeq: number, limit: number): MaybePromise<RunEvent[]>;
listEventsForCommand(runId: string, commandId: string, limit: number): MaybePromise<RunEvent[]>;
createCommand(runId: string, input: CreateCommandInput): MaybePromise<CommandRecord>;
admitSessionTurn(input: SessionTurnAdmissionInput): MaybePromise<SessionTurnAdmission>;
getCommand(commandId: string): MaybePromise<CommandRecord>;
listCommands(runId: string, afterSeq: number, limit: number): MaybePromise<CommandRecord[]>;
getRunnerDispatchIntent(commandId: string): MaybePromise<RunnerDispatchIntentRecord | null>;
claimRunnerDispatchIntents(input: { owner: string; leaseMs: number; limit: number }): MaybePromise<RunnerDispatchIntentRecord[]>;
completeRunnerDispatchIntent(claim: DurableQueueClaim, completion: RunnerDispatchCompletion, eventPayload: JsonRecord): MaybePromise<RunnerDispatchIntentRecord>;
retryRunnerDispatchIntent(claim: DurableQueueClaim, error: JsonRecord, nextAttemptAt: string, eventPayload: JsonRecord): MaybePromise<RunnerDispatchIntentRecord>;
terminalizeRunnerDispatchIntent(claim: DurableQueueClaim, error: JsonRecord): MaybePromise<JsonRecord>;
runnerDispatchStatus(): MaybePromise<JsonRecord>;
claimKafkaEventOutbox(input: { owner: string; leaseMs: number; limit: number }): MaybePromise<KafkaEventOutboxRecord[]>;
completeKafkaEventOutbox(claim: DurableQueueClaim): MaybePromise<KafkaEventOutboxRecord>;
retryKafkaEventOutbox(claim: DurableQueueClaim, error: JsonRecord, nextAttemptAt: string): MaybePromise<KafkaEventOutboxRecord>;
kafkaEventOutboxStatus(): MaybePromise<JsonRecord>;
registerRunner(input: Partial<RunnerRecord>): MaybePromise<RunnerRecord>;
listRunnerJobs(runId: string, commandId?: string): MaybePromise<RunnerJobRecord[]>;
listRunnerJobsForReconciliation(limit: number): MaybePromise<RunnerJobRecord[]>;
getRunnerJobByIdempotencyKey(runId: string, idempotencyKey: string, payloadHash: string): MaybePromise<RunnerJobRecord | null>;
saveRunnerJob(input: SaveRunnerJobInput): MaybePromise<RunnerJobRecord>;
updateRunnerJobResult(runnerJobId: string, patch: JsonRecord): MaybePromise<RunnerJobRecord>;
withRunnerCreateFence<T>(fenceKey: string, operation: () => Promise<T>): MaybePromise<T>;
withRunnerRetentionFence<T>(input: RunnerRetentionFenceInput, operation: (facts: RunnerRetentionFenceFacts) => Promise<T>): MaybePromise<T>;
claimRun(runId: string, runnerId: string, leaseMs: number): MaybePromise<RunRecord>;
heartbeat(runId: string, runnerId: string, leaseMs: number): MaybePromise<RunRecord>;
ackCommand(commandId: string): MaybePromise<CommandRecord>;
finishCommand(commandId: string, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage" | "threadId" | "turnId">): MaybePromise<CommandRecord>;
appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): MaybePromise<RunEvent>;
finishRun(runId: string, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage" | "threadId" | "turnId">): MaybePromise<RunRecord>;
cancelRun(runId: string, reason?: string): MaybePromise<RunRecord>;
cancelCommand(commandId: string, reason?: string): MaybePromise<CommandRecord>;
getSession(sessionId: string): MaybePromise<SessionRecord | null>;
getSessionSummary(sessionId: string, readerId?: string | null): MaybePromise<SessionSummary>;
listSessions(input: ListSessionsInput): MaybePromise<SessionListResult>;
listSessionTrace(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
listSessionOutput(sessionId: string, input: SessionEventPageInput): MaybePromise<SessionEventPage>;
markSessionRead(sessionId: string, readerId: string): MaybePromise<SessionReadCursorRecord>;
upsertSession(input: UpsertSessionInput): MaybePromise<SessionRecord>;
refreshSessionStorage(input: SessionStoragePatch): MaybePromise<SessionRecord>;
markSessionStorageEvicted(input: { sessionId: string; pvcName: string }): MaybePromise<SessionRecord>;
listGcExpiredSessions(input: ListGcExpiredSessionsInput): MaybePromise<SessionRecord[]>;
createQueueTask(input: CreateQueueTaskInput): MaybePromise<QueueTaskRecord>;
listQueueTasks(input: ListQueueTasksInput): MaybePromise<QueueTaskListResult>;
getQueueTask(taskId: string): MaybePromise<QueueTaskRecord>;
listQueueAttempts(taskId: string, input: ListQueueAttemptsInput): MaybePromise<QueueAttemptListResult>;
reserveQueueRetryAttempt(taskId: string, input: ReserveQueueRetryAttemptInput): MaybePromise<QueueRetryReservation>;
recordQueueRetryAttemptFailure(taskId: string, attemptId: string, input: RecordQueueRetryAttemptFailureInput): MaybePromise<QueueAttemptRecord>;
activateQueueRetryAttempt(taskId: string, attemptId: string, input: ActivateQueueRetryAttemptInput): MaybePromise<QueueRetryActivation>;
updateQueueTaskAttempt(taskId: string, input: UpdateQueueTaskAttemptInput): MaybePromise<QueueTaskRecord>;
cancelQueueTask(taskId: string, reason?: string): MaybePromise<QueueTaskRecord>;
markQueueTaskRead(taskId: string, readerId: string): MaybePromise<QueueReadCursorRecord>;
queueStats(queue?: string): MaybePromise<QueueStats>;
queueCommander(queue?: string, readerId?: string | null): MaybePromise<QueueCommanderSnapshot>;
backends(): MaybePromise<JsonRecord[]>;
close?(): MaybePromise<void>;
}
export interface CreateRunIdentity {
id: string;
}
export interface ListQueueTasksInput {
queue?: string;
state?: QueueTaskState;
cursor?: string;
limit: number;
updatedAfter?: number;
}
export interface ListQueueAttemptsInput {
cursor?: number;
limit: number;
}
export interface ReserveQueueRetryAttemptInput {
idempotencyKey: string;
reason: string;
dryRun: boolean;
}
export interface ActivateQueueRetryAttemptInput {
commandId: string;
sessionId: string | null;
sessionPath: string | null;
state: Exclude<QueueTaskState, "pending">;
failureKind: FailureKind | null;
failureMessage: string | null;
}
export interface RecordQueueRetryAttemptFailureInput {
failureKind: FailureKind;
failureMessage: string;
}
export interface ListSessionsInput {
state?: SessionListState;
backendProfile?: BackendProfile;
readerId?: string | null;
cursor?: string;
limit: number;
}
export interface SessionEventPageInput {
runId?: string | null;
afterSeq: number;
limit: number;
}
export interface UpdateQueueTaskAttemptInput {
state: QueueTaskState;
latestAttempt: QueueAttemptRef;
sessionPath: string | null;
}
export interface SaveRunnerJobInput {
id?: string;
runId: string;
commandId: string;
idempotencyKey?: string | null;
payloadHash: string;
attemptId: string;
runnerId: string;
namespace: string;
jobName: string;
managerUrl: string;
image: string;
sourceCommit: string;
serviceAccountName?: string | null;
result: JsonRecord;
}
export async function openAgentRunStoreFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<AgentRunStore> {
const eventOutbox = eventOutboxConfigFromEnv(env);
const databaseUrl = env.DATABASE_URL?.trim();
if (databaseUrl) {
const { createPostgresAgentRunStore } = await import("./postgres-store.js");
const poolMax = optionalPositiveIntegerEnv(env, "AGENTRUN_POSTGRES_POOL_MAX");
return createPostgresAgentRunStore({ connectionString: databaseUrl, ...(poolMax !== undefined ? { poolMax } : {}), eventOutbox });
}
const storeMode = env.AGENTRUN_STORE ?? env.AGENTRUN_MGR_STORE;
if (storeMode === "memory") return new MemoryAgentRunStore({ eventOutbox });
throw new AgentRunError("infra-failed", "DATABASE_URL is required for agentrun-mgr live runtime; set AGENTRUN_STORE=memory only for explicit self-test/dev mode", { httpStatus: 503, details: { adapter: "postgres", databaseUrl: "missing", memoryFallback: "disabled" } });
}
function eventOutboxConfigFromEnv(env: NodeJS.ProcessEnv): EventOutboxConfig {
const enabled = ["1", "true", "yes", "on"].includes(String(env.AGENTRUN_KAFKA_ENABLED ?? "").trim().toLowerCase());
if (!enabled) return { enabled: false, topic: null, source: null };
const topic = env.AGENTRUN_KAFKA_EVENT_TOPIC?.trim();
const source = env.AGENTRUN_KAFKA_CLIENT_ID?.trim();
const missing = [...(!topic ? ["AGENTRUN_KAFKA_EVENT_TOPIC"] : []), ...(!source ? ["AGENTRUN_KAFKA_CLIENT_ID"] : [])];
if (missing.length > 0) throw new AgentRunError("infra-failed", "Kafka event outbox configuration is incomplete", { httpStatus: 503, details: { missing, valuesPrinted: false } });
return { enabled: true, topic: topic as string, source: source as string };
}
function optionalPositiveIntegerEnv(env: NodeJS.ProcessEnv, name: string): number | undefined {
const raw = env[name]?.trim();
if (!raw) return undefined;
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 1) {
throw new AgentRunError("infra-failed", `${name} must be a positive integer`, { httpStatus: 503, details: { env: name, valuesPrinted: false } });
}
return value;
}
export class MemoryAgentRunStore implements AgentRunStore {
private readonly eventOutboxConfig: EventOutboxConfig;
private readonly runs = new Map<string, RunRecord>();
private readonly commands = new Map<string, CommandRecord>();
private readonly eventsByRun = new Map<string, RunEvent[]>();
private readonly runners = new Map<string, RunnerRecord>();
private readonly sessions = new Map<string, SessionRecord>();
private readonly cancelRequests = new Map<string, CancelRequestRecord>();
private readonly sessionReadCursors = new Map<string, SessionReadCursorRecord>();
private readonly runnerJobs = new Map<string, RunnerJobRecord>();
private readonly runnerDispatchIntents = new Map<string, RunnerDispatchIntentRecord>();
private readonly kafkaEventOutbox = new Map<string, KafkaEventOutboxRecord>();
private readonly queueTasks = new Map<string, QueueTaskRecord>();
private readonly queueAttempts = new Map<string, QueueAttemptRecord>();
private readonly queueReadCursors = new Map<string, QueueReadCursorRecord>();
private readonly runnerCreateFenceTails = new Map<string, Promise<void>>();
private readonly runnerRetentionFences = new Set<string>();
private queueVersion = 0;
private sessionVersion = 0;
private kafkaOutboxSeq = 0;
constructor(options: { eventOutbox?: EventOutboxConfig } = {}) {
this.eventOutboxConfig = options.eventOutbox ?? { enabled: false, topic: null, source: null };
}
health(): StoreHealth {
return { adapter: "memory-self-test", ready: true, reachable: true, migrationReady: true, migrationId: "memory-self-test", failureKind: null, message: null, credentialValuesPrinted: false };
}
createRun(input: CreateRunInput, identity?: CreateRunIdentity): RunRecord {
if (identity) {
const existing = this.runs.get(identity.id);
if (existing) {
assertRunCreateReplay(existing, input);
return existing;
}
}
const at = nowIso();
const sessionRef = this.resolveSessionForRun(input, at);
const run: RunRecord = { ...input, sessionRef, resourceBundleRef: input.resourceBundleRef ?? null, id: identity?.id ?? newId("run"), status: "pending", terminalStatus: null, failureKind: null, failureMessage: null, cancelEpoch: 0, cancelRequestId: null, cancelRequestedAt: null, cancelReason: null, createdAt: at, updatedAt: at, claimedBy: null, leaseExpiresAt: null };
this.runs.set(run.id, run);
this.eventsByRun.set(run.id, []);
this.touchSessionForRun(run, { lastRunId: run.id, lastActivityAt: at }, { bumpVersion: false, at });
return run;
}
getRun(runId: string): RunRecord {
const run = this.runs.get(runId);
if (!run) throw new AgentRunError("schema-invalid", `run ${runId} was not found`, { httpStatus: 404 });
return run;
}
listEvents(runId: string, afterSeq: number, limit: number): RunEvent[] {
this.getRun(runId);
return (this.eventsByRun.get(runId) ?? []).filter((event) => event.seq > afterSeq).slice(0, Math.max(1, Math.min(limit, 500)));
}
listEventsForCommand(runId: string, commandId: string, limit: number): RunEvent[] {
this.getRun(runId);
const clamped = Math.max(1, Math.min(limit, 2_000));
return (this.eventsByRun.get(runId) ?? [])
.filter((event) => eventMatchesCommand(event, commandId))
.slice(0, clamped);
}
createCommand(runId: string, input: CreateCommandInput): CommandRecord {
const run = this.getRun(runId);
const payloadHash = stableHash(input.payload);
if (input.idempotencyKey) {
const existing = Array.from(this.commands.values()).find((command) => command.runId === runId && command.idempotencyKey === input.idempotencyKey);
if (existing) {
if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "idempotency key reused with different payload", { httpStatus: 409 });
const intent = this.getRunnerDispatchIntent(existing.id);
assertRunnerDispatchReplay(intent, existing, input.dispatch);
return attachRunnerDispatchIntent(existing, intent);
}
}
if (isTerminalRunStatus(run.status)) throw new AgentRunError(run.failureKind ?? (run.status === "cancelled" ? "cancelled" : "schema-invalid"), `run ${runId} is already terminal: ${run.status}`, { httpStatus: 409 });
const at = nowIso();
const seq = Array.from(this.commands.values()).filter((command) => command.runId === runId).length + 1;
const { dispatch: _dispatch, ...commandInput } = input;
const command: CommandRecord = { ...commandInput, id: newId("cmd"), runId, seq, state: "pending", payloadHash, cancelEpoch: 0, cancelRequestId: null, cancelRequestedAt: null, cancelReason: null, createdAt: at, updatedAt: at, acknowledgedAt: null };
const userMessage = userMessagePayloadForCommand(run, command);
const needsRunCreatedEvent = !(this.eventsByRun.get(runId) ?? []).some(isRunCreatedEvent);
const eventSpecs: Array<{ type: RunEvent["type"]; payload: JsonRecord; command: CommandRecord | null }> = [
...(userMessage ? [{ type: "user_message" as const, payload: userMessage, command }] : []),
...(needsRunCreatedEvent ? [{ type: "backend_status" as const, payload: runCreatedEventPayload(run), command: null }] : []),
{ type: "backend_status", payload: { phase: "command-created", commandId: command.id, commandType: command.type }, command },
];
const eventSeqBase = (this.eventsByRun.get(runId) ?? []).length;
const outboxSeqBase = this.kafkaOutboxSeq;
const preparedEvents = eventSpecs.map((spec, index) => this.prepareEvent(runId, spec.type, spec.payload, {
command: spec.command,
eventSeq: eventSeqBase + index + 1,
outboxSeq: outboxSeqBase + index + 1,
}));
this.commands.set(command.id, command);
const intent = input.dispatch ? newRunnerDispatchIntent(command, input.dispatch, at) : null;
if (intent) this.runnerDispatchIntents.set(intent.id, intent);
if (command.type === "turn") this.touchSessionForRun(run, { executionState: "running", lastRunId: run.id, lastCommandId: command.id, activeRunId: run.id, activeCommandId: command.id, terminalStatus: null, failureKind: null, title: sessionTitleFromCommand(command), lastActivityAt: at }, { bumpVersion: true, at });
else if (command.type === "steer") this.touchSessionForRun(run, { executionState: "running", lastRunId: run.id, lastCommandId: command.id, activeRunId: run.id, lastActivityAt: at }, { bumpVersion: true, at });
for (const prepared of preparedEvents) this.commitPreparedEvent(prepared);
return attachRunnerDispatchIntent(command, intent);
}
admitSessionTurn(input: SessionTurnAdmissionInput): SessionTurnAdmission {
assertSessionTurnAdmissionContract(input);
const session = this.sessions.get(input.sessionId) ?? null;
const replay = input.command.idempotencyKey
? Array.from(this.commands.values())
.filter((command) => command.idempotencyKey === input.command.idempotencyKey)
.map((command) => ({ command, run: this.runs.get(command.runId) ?? null }))
.find((item) => item.run?.sessionRef?.sessionId === input.sessionId) ?? null
: null;
if (replay?.run) return this.replayOrRecoverSessionTurn(replay.run, replay.command, input, "replayed");
if (session?.activeRunId || session?.activeCommandId) {
if (!session.activeRunId || !session.activeCommandId) throw invalidSessionAdmissionProjection(input.sessionId);
const run = this.runs.get(session.activeRunId);
const command = this.commands.get(session.activeCommandId);
if (!run || !command || command.runId !== run.id || run.sessionRef?.sessionId !== input.sessionId) throw invalidSessionAdmissionProjection(input.sessionId);
if (!isTerminalRunStatus(run.status) && !isTerminalCommandState(command.state)) {
try {
return this.replayOrRecoverSessionTurn(run, command, input, "replayed");
} catch (error) {
if (error instanceof AgentRunError && error.failureKind === "schema-invalid") throw activeSessionAdmissionConflict(input.sessionId, run, command);
throw error;
}
}
}
const sessionBefore = session ? { ...session } : null;
const sessionVersionBefore = this.sessionVersion;
const kafkaOutboxSeqBefore = this.kafkaOutboxSeq;
let run: RunRecord | null = null;
try {
run = this.createRun(input.run);
const command = this.createCommand(run.id, input.command);
return { run, command, disposition: "created", recoveredPriorPartialWrite: false, partialWrite: false };
} catch (error) {
if (run) {
this.runs.delete(run.id);
this.eventsByRun.delete(run.id);
for (const [commandId, command] of this.commands) if (command.runId === run.id) this.commands.delete(commandId);
for (const [intentId, intent] of this.runnerDispatchIntents) if (intent.runId === run.id) this.runnerDispatchIntents.delete(intentId);
for (const [outboxId, item] of this.kafkaEventOutbox) if (item.runId === run.id) this.kafkaEventOutbox.delete(outboxId);
}
if (sessionBefore) this.sessions.set(input.sessionId, sessionBefore);
else this.sessions.delete(input.sessionId);
this.sessionVersion = sessionVersionBefore;
this.kafkaOutboxSeq = kafkaOutboxSeqBefore;
throw error;
}
}
private replayOrRecoverSessionTurn(run: RunRecord, command: CommandRecord, input: SessionTurnAdmissionInput, disposition: "replayed" | "recovered"): SessionTurnAdmission {
assertRunCreateReplay(run, input.run);
assertSessionCommandReplay(command, input.command);
const intent = this.getRunnerDispatchIntent(command.id);
if (intent) {
assertRunnerDispatchReplay(intent, command, input.command.dispatch);
return { run, command: attachRunnerDispatchIntent(command, intent), disposition: "replayed", recoveredPriorPartialWrite: false, partialWrite: false };
}
if (!input.command.dispatch) {
return { run, command: attachRunnerDispatchIntent(command, null), disposition, recoveredPriorPartialWrite: false, partialWrite: false };
}
if (run.status !== "pending" || run.claimedBy || command.state !== "pending") throw activeSessionAdmissionConflict(input.sessionId, run, command);
const at = nowIso();
const recovered = newRunnerDispatchIntent(command, input.command.dispatch, at);
const event = this.prepareEvent(run.id, "backend_status", {
phase: "runner-dispatch-recovered",
reason: "session-runner-admission-partial-write-recovered",
commandId: command.id,
dispatchIntentId: recovered.id,
plannedRunnerJobId: recovered.runnerJobId,
valuesPrinted: false,
});
this.runnerDispatchIntents.set(recovered.id, recovered);
this.commitPreparedEvent(event);
return { run, command: attachRunnerDispatchIntent(command, recovered), disposition: "recovered", recoveredPriorPartialWrite: true, partialWrite: false };
}
getRunnerDispatchIntent(commandId: string): RunnerDispatchIntentRecord | null {
return Array.from(this.runnerDispatchIntents.values()).find((intent) => intent.commandId === commandId) ?? null;
}
claimRunnerDispatchIntents(input: { owner: string; leaseMs: number; limit: number }): RunnerDispatchIntentRecord[] {
const now = Date.now();
for (const intent of this.runnerDispatchIntents.values()) {
if (!isActiveRunnerDispatchIntent(intent)) continue;
const run = this.getRun(intent.runId);
const command = this.getCommand(intent.commandId);
if (isTerminalRunStatus(run.status) || isTerminalCommandState(command.state)) this.cancelRunnerDispatchIntent(intent, "run or command is terminal");
}
const claimed = Array.from(this.runnerDispatchIntents.values())
.filter((intent) => (intent.state === "pending" || intent.state === "retry" || (intent.state === "dispatching" && Date.parse(intent.leaseExpiresAt ?? "") <= now)) && Date.parse(intent.nextAttemptAt) <= now)
.sort((a, b) => a.nextAttemptAt.localeCompare(b.nextAttemptAt) || a.createdAt.localeCompare(b.createdAt))
.slice(0, Math.max(1, Math.min(input.limit, 500)));
return claimed.map((intent) => {
const updated: RunnerDispatchIntentRecord = { ...intent, state: "dispatching", attemptCount: intent.attemptCount + 1, leaseOwner: input.owner, leaseExpiresAt: new Date(now + input.leaseMs).toISOString(), updatedAt: nowIso() };
this.runnerDispatchIntents.set(updated.id, updated);
return updated;
});
}
completeRunnerDispatchIntent(claim: DurableQueueClaim, completion: RunnerDispatchCompletion, eventPayload: JsonRecord): RunnerDispatchIntentRecord {
const intent = this.requireRunnerDispatchIntent(claim.id);
this.fenceRunnerDispatchClaim(intent, claim);
const event = this.prepareEvent(intent.runId, "backend_status", eventPayload);
const at = nowIso();
const updated: RunnerDispatchIntentRecord = { ...intent, state: "dispatched", ...completion, leaseOwner: null, leaseExpiresAt: null, lastError: null, dispatchedAt: at, updatedAt: at };
this.runnerDispatchIntents.set(intent.id, updated);
this.commitPreparedEvent(event);
return updated;
}
retryRunnerDispatchIntent(claim: DurableQueueClaim, error: JsonRecord, nextAttemptAt: string, eventPayload: JsonRecord): RunnerDispatchIntentRecord {
const intent = this.requireRunnerDispatchIntent(claim.id);
this.fenceRunnerDispatchClaim(intent, claim);
const event = this.prepareEvent(intent.runId, "backend_status", eventPayload);
const updated: RunnerDispatchIntentRecord = { ...intent, state: "retry", leaseOwner: null, leaseExpiresAt: null, nextAttemptAt, lastError: redactJson(error), updatedAt: nowIso() };
this.runnerDispatchIntents.set(intent.id, updated);
this.commitPreparedEvent(event);
return updated;
}
terminalizeRunnerDispatchIntent(claim: DurableQueueClaim, error: JsonRecord): JsonRecord {
const intent = this.requireRunnerDispatchIntent(claim.id);
this.fenceRunnerDispatchClaim(intent, claim);
const at = nowIso();
const failure = redactJson(error);
const message = typeof failure.message === "string" ? failure.message : "runner dispatch failed";
const reason = typeof failure.reason === "string" ? failure.reason : "runner-dispatch-attempt-failed";
const updatedIntent: RunnerDispatchIntentRecord = { ...intent, state: "failed", leaseOwner: null, leaseExpiresAt: null, nextAttemptAt: at, lastError: failure, updatedAt: at };
this.runnerDispatchIntents.set(intent.id, updatedIntent);
const command = this.getCommand(intent.commandId);
const updatedCommand = isTerminalCommandState(command.state) ? command : { ...command, state: "failed" as const, updatedAt: at };
this.commands.set(command.id, updatedCommand);
const run = this.getRun(intent.runId);
const updatedRun = isTerminalRunStatus(run.status) ? run : this.updateRun(run.id, { status: "failed", terminalStatus: "failed", failureKind: "infra-failed", failureMessage: message });
if (!isTerminalRunStatus(run.status)) {
this.cancelRunnerDispatchIntentsForRun(run.id, "run terminalized by runner dispatch failure", at);
this.appendEvent(run.id, "backend_status", { phase: "runner-dispatch-failed", reason, commandId: command.id, dispatchIntentId: intent.id, attemptCount: intent.attemptCount, failureKind: "infra-failed", message, valuesPrinted: false });
this.appendEvent(run.id, "backend_status", { phase: "command-terminal", commandId: command.id, state: "failed", terminalStatus: "failed", failureKind: "infra-failed", message, threadId: null, turnId: null });
this.appendEvent(run.id, "terminal_status", { terminalStatus: "failed", failureKind: "infra-failed", message });
this.touchSessionForRun(this.getRun(run.id), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: run.id, lastCommandId: command.id, terminalStatus: "failed", failureKind: "infra-failed", lastActivityAt: at }, { bumpVersion: true, at });
}
return { intent: updatedIntent, command: updatedCommand, run: updatedRun, valuesPrinted: false };
}
runnerDispatchStatus(): JsonRecord {
return workQueueStatus(Array.from(this.runnerDispatchIntents.values()), "runner-dispatch");
}
claimKafkaEventOutbox(input: { owner: string; leaseMs: number; limit: number }): KafkaEventOutboxRecord[] {
const now = Date.now();
const all = Array.from(this.kafkaEventOutbox.values());
const claimed = all
.filter((item) => !item.deliveredAt && Date.parse(item.nextAttemptAt) <= now && (!item.leaseExpiresAt || Date.parse(item.leaseExpiresAt) <= now))
.filter((item) => !all.some((earlier) => !earlier.deliveredAt && earlier.topic === item.topic && earlier.partitionKey === item.partitionKey && earlier.outboxSeq < item.outboxSeq))
.sort((a, b) => a.outboxSeq - b.outboxSeq)
.slice(0, Math.max(1, Math.min(input.limit, 500)));
return claimed.map((item) => {
const updated: KafkaEventOutboxRecord = { ...item, attemptCount: item.attemptCount + 1, leaseOwner: input.owner, leaseExpiresAt: new Date(now + input.leaseMs).toISOString(), updatedAt: nowIso() };
this.kafkaEventOutbox.set(item.id, updated);
return updated;
});
}
completeKafkaEventOutbox(claim: DurableQueueClaim): KafkaEventOutboxRecord {
const item = this.requireKafkaEventOutbox(claim.id);
assertMemoryClaim(item, claim, "Kafka event outbox", !item.deliveredAt);
const at = nowIso();
const updated: KafkaEventOutboxRecord = { ...item, deliveredAt: at, leaseOwner: null, leaseExpiresAt: null, lastError: null, updatedAt: at };
this.kafkaEventOutbox.set(item.id, updated);
return updated;
}
retryKafkaEventOutbox(claim: DurableQueueClaim, error: JsonRecord, nextAttemptAt: string): KafkaEventOutboxRecord {
const item = this.requireKafkaEventOutbox(claim.id);
assertMemoryClaim(item, claim, "Kafka event outbox", !item.deliveredAt);
const updated: KafkaEventOutboxRecord = { ...item, leaseOwner: null, leaseExpiresAt: null, nextAttemptAt, lastError: redactJson(error), updatedAt: nowIso() };
this.kafkaEventOutbox.set(item.id, updated);
return updated;
}
kafkaEventOutboxStatus(): JsonRecord {
return { ...workQueueStatus(Array.from(this.kafkaEventOutbox.values()), "kafka-event-outbox", (item) => Boolean((item as KafkaEventOutboxRecord).deliveredAt)), enabled: this.eventOutboxConfig.enabled };
}
getCommand(commandId: string): CommandRecord {
const command = this.commands.get(commandId);
if (!command) throw new AgentRunError("schema-invalid", `command ${commandId} was not found`, { httpStatus: 404 });
return command;
}
listCommands(runId: string, afterSeq: number, limit: number): CommandRecord[] {
this.getRun(runId);
return Array.from(this.commands.values()).filter((command) => command.runId === runId && command.seq > afterSeq).slice(0, Math.max(1, Math.min(limit, 100)));
}
registerRunner(input: Partial<RunnerRecord>): RunnerRecord {
const at = nowIso();
const runner: RunnerRecord = { id: input.id ?? newId("runner"), registeredAt: at, heartbeatAt: at, ...input };
this.runners.set(runner.id, runner);
return runner;
}
listRunnerJobs(runId: string, commandId?: string): RunnerJobRecord[] {
this.getRun(runId);
return Array.from(this.runnerJobs.values())
.filter((job) => job.runId === runId && (!commandId || job.commandId === commandId))
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
listRunnerJobsForReconciliation(limit: number): RunnerJobRecord[] {
const clamped = Math.max(1, Math.min(limit, 500));
return Array.from(this.runnerJobs.values())
.filter((job) => {
const run = this.runs.get(job.runId);
const command = this.commands.get(job.commandId);
return !run || !command || !isTerminalRunStatus(run.status) || !isTerminalCommandState(command.state);
})
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt) || a.createdAt.localeCompare(b.createdAt))
.slice(0, clamped);
}
getRunnerJobByIdempotencyKey(runId: string, idempotencyKey: string, payloadHash: string): RunnerJobRecord | null {
const existing = Array.from(this.runnerJobs.values()).find((job) => job.runId === runId && job.idempotencyKey === idempotencyKey);
if (!existing) return null;
if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "runner job idempotency key reused with different payload", { httpStatus: 409 });
return existing;
}
saveRunnerJob(input: SaveRunnerJobInput): RunnerJobRecord {
if (input.idempotencyKey) {
const existing = this.getRunnerJobByIdempotencyKey(input.runId, input.idempotencyKey, input.payloadHash);
if (existing) return existing;
}
const at = nowIso();
const record: RunnerJobRecord = { ...input, id: input.id ?? newId("rjob"), idempotencyKey: input.idempotencyKey ?? null, serviceAccountName: input.serviceAccountName ?? null, createdAt: at, updatedAt: at };
this.runnerJobs.set(record.id, record);
return record;
}
updateRunnerJobResult(runnerJobId: string, patch: JsonRecord): RunnerJobRecord {
const existing = this.runnerJobs.get(runnerJobId);
if (!existing) throw new AgentRunError("schema-invalid", `runner job ${runnerJobId} was not found`, { httpStatus: 404 });
const next: RunnerJobRecord = { ...existing, result: { ...existing.result, ...patch }, updatedAt: nowIso() };
this.runnerJobs.set(runnerJobId, next);
return next;
}
async withRunnerCreateFence<T>(fenceKey: string, operation: () => Promise<T>): Promise<T> {
const predecessor = this.runnerCreateFenceTails.get(fenceKey) ?? Promise.resolve();
let release = (): void => {};
const current = new Promise<void>((resolve) => { release = resolve; });
const tail = predecessor.then(async () => current);
this.runnerCreateFenceTails.set(fenceKey, tail);
await predecessor;
try {
return await operation();
} finally {
release();
if (this.runnerCreateFenceTails.get(fenceKey) === tail) this.runnerCreateFenceTails.delete(fenceKey);
}
}
async withRunnerRetentionFence<T>(input: RunnerRetentionFenceInput, operation: (facts: RunnerRetentionFenceFacts) => Promise<T>): Promise<T> {
if (this.runnerRetentionFences.has(input.runId)) throw runnerRetentionFenceConflict(input.runId);
const run = this.getRun(input.runId);
const command = this.getCommand(input.commandId);
if (command.runId !== run.id) throw new AgentRunError("schema-invalid", `command ${input.commandId} does not belong to run ${input.runId}`, { httpStatus: 409 });
this.runnerRetentionFences.add(input.runId);
try {
const commands = this.listCommands(run.id, 0, 100);
const events = this.listEvents(run.id, 0, 500);
const result = await operation({ run, command, commands, events });
if (input.terminalizeStalePending && !isTerminalRunStatus(run.status)) this.terminalizeRunnerRetention(run, commands, input.reason);
else if (input.releaseRunnerClaim && (isTerminalRunStatus(run.status) || isTerminalCommandState(command.state))) this.releaseRunnerClaim(run);
return result;
} finally {
this.runnerRetentionFences.delete(input.runId);
}
}
claimRun(runId: string, runnerId: string, leaseMs: number): RunRecord {
if (this.runnerRetentionFences.has(runId)) throw runnerRetentionFenceConflict(runId);
const run = this.getRun(runId);
if (isTerminalRunStatus(run.status)) throw new AgentRunError(run.failureKind ?? (run.status === "cancelled" ? "cancelled" : "schema-invalid"), `run ${runId} is already terminal: ${run.status}`, { httpStatus: 409 });
if (run.claimedBy && run.claimedBy !== runnerId && !isLeaseExpired(run.leaseExpiresAt)) throw new AgentRunError("runner-lease-conflict", `run ${runId} is already claimed`, { httpStatus: 409 });
const next = this.updateRun(runId, { status: "claimed", claimedBy: runnerId, leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() });
this.touchSessionForRun(next, { executionState: "running", activeRunId: runId, lastRunId: runId, lastActivityAt: next.updatedAt }, { bumpVersion: false, at: next.updatedAt });
this.appendEvent(runId, "backend_status", { phase: "run-claimed", runnerId });
return next;
}
heartbeat(runId: string, runnerId: string, leaseMs: number): RunRecord {
if (this.runnerRetentionFences.has(runId)) throw runnerRetentionFenceConflict(runId);
const run = this.getRun(runId);
if (isTerminalRunStatus(run.status)) return run;
if (run.claimedBy !== runnerId) throw new AgentRunError("runner-lease-conflict", `run ${runId} is not claimed by ${runnerId}`, { httpStatus: 409 });
return this.updateRun(runId, { leaseExpiresAt: new Date(Date.now() + leaseMs).toISOString() });
}
ackCommand(commandId: string): CommandRecord {
const command = this.getCommand(commandId);
if (isTerminalCommandState(command.state) || command.state === "acknowledged") return command;
const next = { ...command, state: "acknowledged" as const, acknowledgedAt: nowIso(), updatedAt: nowIso() };
this.commands.set(commandId, next);
return next;
}
finishCommand(commandId: string, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage" | "threadId" | "turnId">): CommandRecord {
const command = this.getCommand(commandId);
let run = this.getRun(command.runId);
if (isTerminalRunStatus(run.status)) run = this.releaseTerminalRunClaim(run);
if (isTerminalCommandState(command.state)) {
this.cancelRunnerDispatchIntentForCommand(commandId, "command is terminal");
if (command.state === "cancelled" && result.terminalStatus !== "cancelled") this.appendLateWriteRejected(run.id, command, result, "command-status");
return command;
}
const next = { ...command, state: commandStateFromTerminal(result.terminalStatus), updatedAt: nowIso() };
this.commands.set(commandId, next);
this.cancelRunnerDispatchIntentForCommand(commandId, `command terminalized as ${next.state}`, next.updatedAt);
if (result.threadId && run.sessionRef?.sessionId) this.upsertSessionThread(run, result.threadId, result.turnId ?? null);
if (command.type === "turn") this.touchSessionForRun(this.getRun(command.runId), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: command.runId, lastCommandId: command.id, terminalStatus: result.terminalStatus, failureKind: result.failureKind, lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: next.state, terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage ?? null, threadId: result.threadId ?? null, turnId: result.turnId ?? null });
return next;
}
appendEvent(runId: string, type: RunEvent["type"], payload: JsonRecord): RunEvent {
const prepared = this.prepareEvent(runId, requireExternallyAppendableEventType(type), payload);
this.commitPreparedEvent(prepared);
return prepared.event;
}
protected beforeEventCommit(_event: RunEvent): void {}
private prepareEvent(runId: string, type: RunEvent["type"], payload: JsonRecord, options: { command?: CommandRecord | null; eventSeq?: number; outboxSeq?: number } = {}): { run: RunRecord; event: RunEvent; outbox: KafkaEventOutboxRecord | null } {
const run = this.getRun(runId);
const eventType = requireEventType(type);
const fenced = fenceLateEventForCancelledRun(run, eventType, payload);
const eventPayload = normalizeRunEventPayload(fenced.type, fenced.payload);
const events = this.eventsByRun.get(runId) ?? [];
const event: RunEvent = { id: newId("evt"), runId, seq: options.eventSeq ?? events.length + 1, type: fenced.type, payload: redactJson(eventPayload), createdAt: nowIso() };
let outbox: KafkaEventOutboxRecord | null = null;
if (this.eventOutboxConfig.enabled) {
const command = options.command === undefined ? commandForEvent(this.commands, event) : options.command;
outbox = buildKafkaEventOutboxRecord({ source: this.eventOutboxConfig.source as string, topic: this.eventOutboxConfig.topic as string, outboxSeq: options.outboxSeq ?? this.kafkaOutboxSeq + 1, run, command, event });
}
this.beforeEventCommit(event);
return { run, event, outbox };
}
private commitPreparedEvent(prepared: { run: RunRecord; event: RunEvent; outbox: KafkaEventOutboxRecord | null }): void {
const { run, event, outbox } = prepared;
const events = this.eventsByRun.get(run.id) ?? [];
events.push(event);
this.eventsByRun.set(run.id, events);
if (outbox) {
this.kafkaOutboxSeq = outbox.outboxSeq;
this.kafkaEventOutbox.set(outbox.id, outbox);
}
this.touchSessionForRun(this.getRun(run.id), { lastEventSeq: event.seq, lastActivityAt: event.createdAt }, { bumpVersion: false, at: event.createdAt });
}
finishRun(runId: string, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage" | "threadId" | "turnId">): RunRecord {
const existing = this.getRun(runId);
if (isTerminalRunStatus(existing.status)) {
this.cancelRunnerDispatchIntentsForRun(runId, "run is terminal");
const released = this.releaseTerminalRunClaim(existing);
if (released.status === "cancelled" && result.terminalStatus !== "cancelled") this.appendLateWriteRejected(runId, null, result, "run-status");
return released;
}
const status = statusFromTerminal(result.terminalStatus);
const next = this.updateRun(runId, { status, terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: result.failureMessage, claimedBy: null, leaseExpiresAt: null });
this.cancelRunnerDispatchIntentsForRun(runId, `run terminalized as ${status}`, next.updatedAt);
if (result.threadId && next.sessionRef?.sessionId) this.upsertSessionThread(next, result.threadId, result.turnId ?? null);
this.appendEvent(runId, "terminal_status", { terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage });
this.touchSessionForRun(this.getRun(runId), { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: runId, terminalStatus: result.terminalStatus, failureKind: result.failureKind, lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
return next;
}
cancelRun(runId: string, reason = "cancel requested"): RunRecord {
const run = this.getRun(runId);
if (isTerminalRunStatus(run.status)) {
this.cancelRunnerDispatchIntentsForRun(runId, reason);
return this.releaseTerminalRunClaim(run);
}
const at = nowIso();
const cancel = this.createCancelRequest({ targetKind: "run", targetId: runId, run, command: null, reason, at, stage: "accepted" });
this.appendCancelStage(runId, "accepted", cancel);
const persistedRun = this.updateRun(runId, { cancelEpoch: cancel.epoch, cancelRequestId: cancel.id, cancelRequestedAt: at, cancelReason: reason });
this.appendCancelStage(runId, "persisted", cancel);
const leaseExpired = Boolean(persistedRun.claimedBy && isLeaseExpired(persistedRun.leaseExpiresAt));
if (leaseExpired) this.appendCancelStage(runId, "fenced", cancel, { claimedBy: persistedRun.claimedBy, leaseExpiresAt: persistedRun.leaseExpiresAt });
else if (persistedRun.claimedBy) {
this.appendCancelStage(runId, "delivered", cancel, { claimedBy: persistedRun.claimedBy });
this.appendCancelStage(runId, "aborting", cancel, { claimedBy: persistedRun.claimedBy });
}
for (const command of Array.from(this.commands.values()).filter((item) => item.runId === runId && !isTerminalCommandState(item.state))) {
const cancelled = { ...command, state: "cancelled" as const, cancelEpoch: cancel.epoch, cancelRequestId: cancel.id, cancelRequestedAt: at, cancelReason: reason, updatedAt: at };
this.commands.set(command.id, cancelled);
this.appendEvent(runId, "backend_status", { phase: "command-terminal", commandId: command.id, state: "cancelled", terminalStatus: "cancelled", failureKind: "cancelled", cancelRequestId: cancel.id, cancelEpoch: cancel.epoch });
}
this.cancelRunnerDispatchIntentsForRun(runId, reason, at);
const next = this.updateRun(runId, { status: "cancelled", terminalStatus: "cancelled", failureKind: "cancelled", failureMessage: reason, claimedBy: null, leaseExpiresAt: null });
this.appendEvent(runId, "terminal_status", { terminalStatus: "cancelled", failureKind: "cancelled", message: reason, cancelRequestId: cancel.id, cancelEpoch: cancel.epoch });
this.appendCancelStage(runId, "terminalized", cancel);
this.touchSessionForRun(next, { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: runId, terminalStatus: "cancelled", failureKind: "cancelled", lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
return next;
}
cancelCommand(commandId: string, reason = "cancel requested"): CommandRecord {
const command = this.getCommand(commandId);
const run = this.getRun(command.runId);
if (isTerminalRunStatus(run.status)) this.releaseTerminalRunClaim(run);
if (isTerminalCommandState(command.state)) {
this.cancelRunnerDispatchIntentForCommand(commandId, reason);
return command;
}
const at = nowIso();
const cancel = this.createCancelRequest({ targetKind: "command", targetId: commandId, run, command, reason, at, stage: "accepted" });
this.appendCancelStage(command.runId, "accepted", cancel);
const next = { ...command, state: "cancelled" as const, cancelEpoch: cancel.epoch, cancelRequestId: cancel.id, cancelRequestedAt: at, cancelReason: reason, updatedAt: at };
this.commands.set(commandId, next);
this.cancelRunnerDispatchIntentForCommand(commandId, reason, at);
this.appendCancelStage(command.runId, "persisted", cancel);
const leaseExpired = Boolean(run.claimedBy && isLeaseExpired(run.leaseExpiresAt));
if (leaseExpired) this.appendCancelStage(command.runId, "fenced", cancel, { claimedBy: run.claimedBy, leaseExpiresAt: run.leaseExpiresAt });
else if (run.claimedBy || command.state === "acknowledged") {
this.appendCancelStage(command.runId, "delivered", cancel, { claimedBy: run.claimedBy, commandState: command.state });
this.appendCancelStage(command.runId, "aborting", cancel, { claimedBy: run.claimedBy, commandState: command.state });
}
this.appendEvent(command.runId, "backend_status", { phase: "command-terminal", commandId, state: "cancelled", terminalStatus: "cancelled", failureKind: "cancelled", message: reason, cancelRequestId: cancel.id, cancelEpoch: cancel.epoch });
this.appendCancelStage(command.runId, "terminalized", cancel);
if (command.type === "turn") this.touchSessionForRun(run, { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: command.runId, lastCommandId: command.id, terminalStatus: "cancelled", failureKind: "cancelled", lastActivityAt: next.updatedAt }, { bumpVersion: true, at: next.updatedAt });
return next;
}
getSession(sessionId: string): SessionRecord | null {
return this.sessions.get(sessionId) ?? null;
}
getSessionSummary(sessionId: string, readerId: string | null = null): SessionSummary {
const session = this.getSession(sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
return buildSessionSummary(session, readerId, readerId ? this.sessionReadCursors.get(sessionReadKey(sessionId, readerId)) ?? null : null);
}
listSessions(input: ListSessionsInput): SessionListResult {
const startVersion = parseSessionCursor(input.cursor) ?? 0;
const state = input.state ?? "default";
const items = Array.from(this.sessions.values())
.map((session) => buildSessionSummary(session, input.readerId ?? null, input.readerId ? this.sessionReadCursors.get(sessionReadKey(session.sessionId, input.readerId)) ?? null : null))
.filter((session) => session.version > startVersion)
.filter((session) => !input.backendProfile || session.backendProfile === input.backendProfile)
.filter((session) => sessionMatchesListState(session, state))
.sort(sessionSort)
.slice(0, clampSessionLimit(input.limit));
return { items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.version ?? startVersion) : null, filters: sessionListFilters(input) };
}
listSessionTrace(sessionId: string, input: SessionEventPageInput): SessionEventPage {
const runId = this.resolveSessionRunId(sessionId, input.runId ?? null);
if (!runId) return { sessionId, runId: null, items: [], count: 0, cursor: null };
const items = this.listEvents(runId, input.afterSeq, input.limit);
return { sessionId, runId, items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.seq ?? input.afterSeq) : null };
}
listSessionOutput(sessionId: string, input: SessionEventPageInput): SessionEventPage {
const page = this.listSessionTrace(sessionId, input);
const items = page.items.filter(isSessionOutputEvent);
return { ...page, items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.seq ?? input.afterSeq) : null };
}
markSessionRead(sessionId: string, readerId: string): SessionReadCursorRecord {
const session = this.getSession(sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
const record: SessionReadCursorRecord = { sessionId, readerId, sessionVersion: session.version, readAt: nowIso() };
this.sessionReadCursors.set(sessionReadKey(sessionId, readerId), record);
return record;
}
upsertSession(input: UpsertSessionInput): SessionRecord {
const at = nowIso();
const existing = this.sessions.get(input.sessionId);
if (existing) {
const next: SessionRecord = {
...existing,
tenantId: input.tenantId,
projectId: input.projectId,
backendProfile: input.backendProfile,
conversationId: input.conversationId,
threadId: input.threadId,
metadata: input.metadata,
expiresAt: input.expiresAt,
codexRolloutSubdir: input.codexRolloutSubdir,
version: existing.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
const next: SessionRecord = {
sessionId: input.sessionId,
tenantId: input.tenantId,
projectId: input.projectId,
backendProfile: input.backendProfile,
conversationId: input.conversationId,
threadId: input.threadId,
metadata: input.metadata,
version: 1,
executionState: "idle",
lastRunId: null,
lastCommandId: null,
activeRunId: null,
activeCommandId: null,
lastEventSeq: 0,
terminalStatus: null,
failureKind: null,
title: null,
summary: {},
lastActivityAt: null,
createdAt: at,
updatedAt: at,
expiresAt: input.expiresAt,
storageKind: "none",
codexRolloutSubdir: input.codexRolloutSubdir,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
refreshSessionStorage(input: SessionStoragePatch): SessionRecord {
const session = this.sessions.get(input.sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const at = nowIso();
const next: SessionRecord = {
...session,
storageKind: input.storageKind,
storagePvcName: input.pvcName ?? null,
storageNamespace: input.storageNamespace ?? null,
storagePvcPhase: input.pvcPhase ?? null,
storageSizeBytes: input.storageSizeBytes ?? null,
storageFilesCount: input.storageFilesCount ?? null,
storageSha256: input.storageSha256 ?? null,
codexRolloutSubdir: input.codexRolloutSubdir ?? session.codexRolloutSubdir ?? "sessions",
storageUpdatedAt: at,
version: session.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
markSessionStorageEvicted(input: { sessionId: string; pvcName: string }): SessionRecord {
const session = this.sessions.get(input.sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${input.sessionId} was not found`, { httpStatus: 404 });
const at = nowIso();
const next: SessionRecord = {
...session,
storageKind: "evicted",
storagePvcName: input.pvcName,
storageEvictedAt: at,
storageUpdatedAt: at,
version: session.version + 1,
updatedAt: at,
};
this.sessions.set(input.sessionId, next);
this.sessionVersion = Math.max(this.sessionVersion, next.version) + 1;
return next;
}
listGcExpiredSessions(input: ListGcExpiredSessionsInput): SessionRecord[] {
const now = input.now;
return Array.from(this.sessions.values())
.filter((session) => session.storageKind === "pvc" && Boolean(session.storagePvcName))
.filter((session) => session.expiresAt !== null && Date.parse(session.expiresAt ?? "") <= now)
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
.slice(0, Math.max(1, input.limit));
}
createQueueTask(input: CreateQueueTaskInput): QueueTaskRecord {
const payloadHash = queueTaskPayloadHash(input);
if (input.idempotencyKey) {
const existing = Array.from(this.queueTasks.values()).find((task) => task.tenantId === input.tenantId && task.projectId === input.projectId && task.idempotencyKey === input.idempotencyKey);
if (existing) {
if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "queue task idempotency key reused with different payload", { httpStatus: 409 });
return existing;
}
}
const at = nowIso();
const sessionId = input.sessionRef?.sessionId ?? null;
const sessionPath = sessionId ? `/api/v1/sessions/${encodeURIComponent(sessionId)}` : null;
const task: QueueTaskRecord = { ...input, id: newId("qt"), state: "pending", version: this.nextQueueVersion(), payloadHash, latestAttempt: null, sessionPath, createdAt: at, updatedAt: at, cancelledAt: null, cancelReason: null };
this.queueTasks.set(task.id, task);
return task;
}
listQueueTasks(input: ListQueueTasksInput): QueueTaskListResult {
const startVersion = parseQueueCursor(input.cursor) ?? input.updatedAfter ?? 0;
const items = Array.from(this.queueTasks.values())
.filter((task) => task.version > startVersion)
.filter((task) => !input.queue || task.queue === input.queue)
.filter((task) => !input.state || task.state === input.state)
.sort(queueTaskSort)
.slice(0, clampQueueLimit(input.limit));
return { items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.version ?? startVersion) : null };
}
getQueueTask(taskId: string): QueueTaskRecord {
const task = this.queueTasks.get(taskId);
if (!task) throw new AgentRunError("schema-invalid", `queue task ${taskId} was not found`, { httpStatus: 404 });
return task;
}
listQueueAttempts(taskId: string, input: ListQueueAttemptsInput): QueueAttemptListResult {
this.getQueueTask(taskId);
const cursor = input.cursor ?? Number.POSITIVE_INFINITY;
const items = Array.from(this.queueAttempts.values())
.filter((attempt) => attempt.taskId === taskId && attempt.retryIndex < cursor)
.sort((a, b) => b.retryIndex - a.retryIndex || b.createdAt.localeCompare(a.createdAt))
.slice(0, clampQueueLimit(input.limit));
return { taskId, items, count: items.length, cursor: items.length === clampQueueLimit(input.limit) ? String(items.at(-1)?.retryIndex ?? "") : null };
}
reserveQueueRetryAttempt(taskId: string, input: ReserveQueueRetryAttemptInput): QueueRetryReservation {
const task = this.getQueueTask(taskId);
assertQueueTaskPayloadHash(task);
const attempts = this.queueAttemptsForTask(taskId);
const existing = attempts.find((attempt) => attempt.idempotencyKey === input.idempotencyKey) ?? null;
if (existing) {
if (existing.reason !== input.reason) throw new AgentRunError("schema-invalid", "queue retry idempotency key reused with different reason", { httpStatus: 409 });
return queueRetryReservation(task, existing, attempts, false, true);
}
assertQueueRetryable(task, attempts);
const retryIndex = nextQueueRetryIndex(attempts);
if (input.dryRun) return queueRetryReservation(task, null, attempts, false, false, retryIndex);
const at = nowIso();
const attempt: QueueAttemptRecord = {
attemptId: newId("qa"),
taskId,
retryIndex,
state: "reserved",
idempotencyKey: input.idempotencyKey,
reason: input.reason,
payloadHash: task.payloadHash,
previousAttemptId: latestQueueAttempt(attempts)?.attemptId ?? task.latestAttempt?.attemptId ?? null,
runId: newId("run"),
commandId: null,
runnerJobId: newId("rjob"),
sessionId: task.sessionRef?.sessionId ?? null,
sessionPath: task.sessionPath,
failureKind: null,
failureMessage: null,
createdAt: at,
updatedAt: at,
activatedAt: null,
terminalAt: null,
};
this.queueAttempts.set(queueAttemptKey(taskId, attempt.attemptId), attempt);
return queueRetryReservation(task, attempt, attempts, true, false);
}
recordQueueRetryAttemptFailure(taskId: string, attemptId: string, input: RecordQueueRetryAttemptFailureInput): QueueAttemptRecord {
this.getQueueTask(taskId);
const attemptKey = queueAttemptKey(taskId, attemptId);
const attempt = this.queueAttempts.get(attemptKey);
if (!attempt) throw new AgentRunError("schema-invalid", `queue attempt ${attemptId} was not found for task ${taskId}`, { httpStatus: 404 });
if (attempt.state !== "reserved") return attempt;
const next: QueueAttemptRecord = { ...attempt, failureKind: input.failureKind, failureMessage: input.failureMessage, updatedAt: nowIso() };
this.queueAttempts.set(attemptKey, next);
return next;
}
activateQueueRetryAttempt(taskId: string, attemptId: string, input: ActivateQueueRetryAttemptInput): QueueRetryActivation {
const task = this.getQueueTask(taskId);
const attempt = this.queueAttempts.get(queueAttemptKey(taskId, attemptId));
if (!attempt) throw new AgentRunError("schema-invalid", `queue attempt ${attemptId} was not found for task ${taskId}`, { httpStatus: 404 });
if (attempt.state !== "reserved") {
assertQueueAttemptActivationReplay(attempt, input);
return { task, attempt, activated: false };
}
if (task.state !== "failed" && task.state !== "blocked") throw queueRetryStateError(task);
const at = nowIso();
const nextAttempt: QueueAttemptRecord = {
...attempt,
state: input.state,
commandId: input.commandId,
sessionId: input.sessionId,
sessionPath: input.sessionPath,
failureKind: input.failureKind,
failureMessage: input.failureMessage,
updatedAt: at,
activatedAt: at,
terminalAt: isTerminalQueueTaskState(input.state) ? at : null,
};
const latestAttempt = queueAttemptRef(nextAttempt);
const nextTask: QueueTaskRecord = { ...task, state: input.state, latestAttempt, sessionPath: input.sessionPath, version: this.nextQueueVersion(), updatedAt: at };
this.queueAttempts.set(queueAttemptKey(taskId, attemptId), nextAttempt);
this.queueTasks.set(taskId, nextTask);
return { task: nextTask, attempt: nextAttempt, activated: true };
}
updateQueueTaskAttempt(taskId: string, input: UpdateQueueTaskAttemptInput): QueueTaskRecord {
const task = this.getQueueTask(taskId);
if (isTerminalQueueTaskState(task.state)) throw new AgentRunError(task.state === "cancelled" ? "cancelled" : "schema-invalid", `queue task ${taskId} is already terminal: ${task.state}`, { httpStatus: 409 });
const next: QueueTaskRecord = { ...task, state: input.state, latestAttempt: input.latestAttempt, sessionPath: input.sessionPath, version: this.nextQueueVersion(), updatedAt: nowIso() };
this.upsertQueueAttemptProjection(task, input.latestAttempt, next.updatedAt);
this.queueTasks.set(taskId, next);
return next;
}
cancelQueueTask(taskId: string, reason = "cancel requested"): QueueTaskRecord {
const task = this.getQueueTask(taskId);
if (isTerminalQueueTaskState(task.state)) return task;
if (task.latestAttempt?.runId) this.cancelRun(task.latestAttempt.runId, reason);
else if (task.latestAttempt?.commandId) this.cancelCommand(task.latestAttempt.commandId, reason);
const at = nowIso();
const latestAttempt = task.latestAttempt ? { ...task.latestAttempt, state: "cancelled" as const, failureKind: "cancelled" as const, failureMessage: reason } : null;
const next: QueueTaskRecord = { ...task, state: "cancelled", latestAttempt, version: this.nextQueueVersion(), updatedAt: at, cancelledAt: at, cancelReason: reason };
if (latestAttempt) this.upsertQueueAttemptProjection(task, latestAttempt, at);
this.queueTasks.set(taskId, next);
return next;
}
markQueueTaskRead(taskId: string, readerId: string): QueueReadCursorRecord {
const task = this.getQueueTask(taskId);
const record: QueueReadCursorRecord = { taskId, readerId, taskVersion: task.version, readAt: nowIso() };
this.queueReadCursors.set(queueReadKey(taskId, readerId), record);
return record;
}
queueStats(queue?: string): QueueStats {
return buildQueueStats(Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue), queue ?? null);
}
queueCommander(queue?: string, readerId: string | null = null): QueueCommanderSnapshot {
const tasks = Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue);
const generatedAt = nowIso();
const items = tasks
.map((task) => buildQueueTaskSummary(task, readerId, readerId ? this.queueReadCursors.get(queueReadKey(task.id, readerId)) ?? null : null))
.filter((task) => queueTaskMatchesCommander(task, readerId))
.sort(queueTaskSort)
.slice(0, 20);
return { queue: queue ?? null, readerId, stats: buildQueueStats(tasks, queue ?? null, generatedAt), items, generatedAt };
}
private queueAttemptsForTask(taskId: string): QueueAttemptRecord[] {
return Array.from(this.queueAttempts.values()).filter((attempt) => attempt.taskId === taskId).sort((a, b) => a.retryIndex - b.retryIndex || a.createdAt.localeCompare(b.createdAt));
}
private upsertQueueAttemptProjection(task: QueueTaskRecord, ref: QueueAttemptRef, at: string): void {
const attemptKey = queueAttemptKey(task.id, ref.attemptId);
const existing = this.queueAttempts.get(attemptKey);
if (existing) {
if (isTerminalQueueAttemptState(existing.state)) {
if (stableHash(queueAttemptRef(existing)) !== stableHash(ref)) throw new AgentRunError("schema-invalid", `queue attempt ${ref.attemptId} is terminal and immutable`, { httpStatus: 409 });
return;
}
const next: QueueAttemptRecord = {
...existing,
...ref,
state: ref.state,
failureKind: ref.failureKind ?? null,
failureMessage: ref.failureMessage ?? null,
updatedAt: at,
activatedAt: existing.activatedAt ?? at,
terminalAt: isTerminalQueueTaskState(ref.state) ? at : null,
};
this.queueAttempts.set(attemptKey, next);
return;
}
const attempts = this.queueAttemptsForTask(task.id);
const record: QueueAttemptRecord = {
attemptId: ref.attemptId,
taskId: task.id,
retryIndex: nextQueueRetryIndex(attempts),
state: ref.state,
idempotencyKey: null,
reason: null,
payloadHash: task.payloadHash,
previousAttemptId: latestQueueAttempt(attempts)?.attemptId ?? null,
runId: ref.runId,
commandId: ref.commandId,
runnerJobId: ref.runnerJobId,
sessionId: ref.sessionId,
sessionPath: ref.sessionPath,
failureKind: ref.failureKind ?? null,
failureMessage: ref.failureMessage ?? null,
createdAt: at,
updatedAt: at,
activatedAt: at,
terminalAt: isTerminalQueueTaskState(ref.state) ? at : null,
};
this.queueAttempts.set(attemptKey, record);
}
backends(): JsonRecord[] {
return backendCapabilities();
}
private updateRun(runId: string, patch: Partial<RunRecord>): RunRecord {
const run = this.getRun(runId);
const next = { ...run, ...patch, updatedAt: nowIso() };
this.runs.set(runId, next);
return next;
}
private releaseTerminalRunClaim(run: RunRecord): RunRecord {
if (!isTerminalRunStatus(run.status)) return run;
return this.releaseRunnerClaim(run);
}
private releaseRunnerClaim(run: RunRecord): RunRecord {
if (run.claimedBy === null && run.leaseExpiresAt === null) return run;
return this.updateRun(run.id, { claimedBy: null, leaseExpiresAt: null });
}
private terminalizeRunnerRetention(run: RunRecord, commands: CommandRecord[], reason: string): void {
const at = nowIso();
for (const command of commands.filter((item) => !isTerminalCommandState(item.state))) {
this.commands.set(command.id, { ...command, state: "failed", updatedAt: at });
this.cancelRunnerDispatchIntentForCommand(command.id, reason, at);
const prepared = this.prepareEvent(run.id, "backend_status", { phase: "command-terminal", commandId: command.id, state: "failed", terminalStatus: "failed", failureKind: "infra-failed", message: reason, retentionFence: true });
this.commitPreparedEvent(prepared);
}
const next = this.updateRun(run.id, { status: "failed", terminalStatus: "failed", failureKind: "infra-failed", failureMessage: reason, claimedBy: null, leaseExpiresAt: null });
this.cancelRunnerDispatchIntentsForRun(run.id, reason, at);
this.commitPreparedEvent(this.prepareEvent(run.id, "terminal_status", { terminalStatus: "failed", failureKind: "infra-failed", message: reason, retentionFence: true }));
this.touchSessionForRun(next, { executionState: "terminal", activeRunId: null, activeCommandId: null, lastRunId: run.id, terminalStatus: "failed", failureKind: "infra-failed", lastActivityAt: at }, { bumpVersion: true, at });
}
private requireRunnerDispatchIntent(intentId: string): RunnerDispatchIntentRecord {
const intent = this.runnerDispatchIntents.get(intentId);
if (!intent) throw new AgentRunError("schema-invalid", `runner dispatch intent ${intentId} was not found`, { httpStatus: 404 });
return intent;
}
private fenceRunnerDispatchClaim(intent: RunnerDispatchIntentRecord, claim: DurableQueueClaim): void {
assertMemoryClaim(intent, claim, "runner dispatch", intent.state === "dispatching");
const run = this.getRun(intent.runId);
const command = this.getCommand(intent.commandId);
if (!isTerminalRunStatus(run.status) && !isTerminalCommandState(command.state)) return;
this.cancelRunnerDispatchIntent(intent, "run or command became terminal during dispatch");
throw staleClaimError("runner dispatch", claim);
}
private cancelRunnerDispatchIntentForCommand(commandId: string, reason: string, at = nowIso()): void {
const intent = this.getRunnerDispatchIntent(commandId);
if (intent) this.cancelRunnerDispatchIntent(intent, reason, at);
}
private cancelRunnerDispatchIntentsForRun(runId: string, reason: string, at = nowIso()): void {
for (const intent of this.runnerDispatchIntents.values()) if (intent.runId === runId) this.cancelRunnerDispatchIntent(intent, reason, at);
}
private cancelRunnerDispatchIntent(intent: RunnerDispatchIntentRecord, reason: string, at = nowIso()): void {
if (!isActiveRunnerDispatchIntent(intent)) return;
this.runnerDispatchIntents.set(intent.id, { ...intent, state: "cancelled", leaseOwner: null, leaseExpiresAt: null, lastError: { code: "cancelled", message: reason, valuesPrinted: false }, updatedAt: at });
}
private requireKafkaEventOutbox(outboxId: string): KafkaEventOutboxRecord {
const item = this.kafkaEventOutbox.get(outboxId);
if (!item) throw new AgentRunError("schema-invalid", `Kafka event outbox ${outboxId} was not found`, { httpStatus: 404 });
return item;
}
private createCancelRequest(input: { targetKind: CancelTargetKind; targetId: string; run: RunRecord; command: CommandRecord | null; reason: string; at: string; stage: CancelStage }): CancelRequestRecord {
const sessionId = input.run.sessionRef?.sessionId ?? null;
const epoch = input.command ? input.command.cancelEpoch + 1 : input.run.cancelEpoch + 1;
const record: CancelRequestRecord = {
id: newId("cancel"),
targetKind: input.targetKind,
targetId: input.targetId,
runId: input.run.id,
commandId: input.command?.id ?? null,
sessionId,
taskId: null,
reason: input.reason,
requestedBy: null,
epoch,
stage: input.stage,
metadata: {},
createdAt: input.at,
updatedAt: input.at,
};
this.cancelRequests.set(record.id, record);
return record;
}
private appendCancelStage(runId: string, stage: CancelStage, cancel: CancelRequestRecord, extra: JsonRecord = {}): void {
const next: CancelRequestRecord = { ...cancel, stage, updatedAt: nowIso() };
this.cancelRequests.set(cancel.id, next);
this.appendEvent(runId, "backend_status", cancelStagePayload(next, stage, extra));
}
private appendLateWriteRejected(runId: string, command: CommandRecord | null, result: Pick<BackendTurnResult, "terminalStatus" | "failureKind" | "failureMessage" | "threadId" | "turnId">, source: string): void {
this.appendEvent(runId, "backend_status", lateWriteRejectedPayload(this.getRun(runId), command, { source, terminalStatus: result.terminalStatus, failureKind: result.failureKind, message: result.failureMessage ?? null, threadId: result.threadId ?? null, turnId: result.turnId ?? null }));
}
private nextQueueVersion(): number {
this.queueVersion += 1;
return this.queueVersion;
}
private resolveSessionForRun(input: CreateRunInput, at: string): SessionRef | null {
if (!input.sessionRef) return null;
const existing = this.sessions.get(input.sessionRef.sessionId);
if (existing) {
assertSessionBoundary(existing, input);
return sessionRefFromRecord(existing, input.sessionRef);
}
const record: SessionRecord = {
sessionId: input.sessionRef.sessionId,
tenantId: input.tenantId,
projectId: input.projectId,
backendProfile: input.backendProfile,
conversationId: input.sessionRef.conversationId ?? null,
threadId: input.sessionRef.threadId ?? null,
metadata: input.sessionRef.metadata ?? {},
version: this.nextSessionVersion(),
executionState: "idle",
lastRunId: null,
lastCommandId: null,
activeRunId: null,
activeCommandId: null,
lastEventSeq: 0,
terminalStatus: null,
failureKind: null,
title: titleFromMetadata(input.sessionRef.metadata ?? {}),
summary: {},
lastActivityAt: at,
createdAt: at,
updatedAt: at,
expiresAt: input.sessionRef.expiresAt ?? null,
};
this.sessions.set(record.sessionId, record);
return sessionRefFromRecord(record, input.sessionRef);
}
private upsertSessionThread(run: RunRecord, threadId: string, turnId: string | null): void {
if (!run.sessionRef?.sessionId) return;
const at = nowIso();
const existing = this.sessions.get(run.sessionRef.sessionId);
const record: SessionRecord = {
sessionId: run.sessionRef.sessionId,
tenantId: run.tenantId,
projectId: run.projectId,
backendProfile: run.backendProfile,
conversationId: run.sessionRef.conversationId ?? existing?.conversationId ?? null,
threadId,
metadata: { ...(existing?.metadata ?? {}), ...(run.sessionRef.metadata ?? {}), ...(turnId ? { lastTurnId: turnId } : {}) },
version: this.nextSessionVersion(),
executionState: existing?.executionState ?? "idle",
lastRunId: existing?.lastRunId ?? run.id,
lastCommandId: existing?.lastCommandId ?? null,
activeRunId: existing?.activeRunId ?? null,
activeCommandId: existing?.activeCommandId ?? null,
lastEventSeq: existing?.lastEventSeq ?? 0,
terminalStatus: existing?.terminalStatus ?? null,
failureKind: existing?.failureKind ?? null,
title: existing?.title ?? titleFromMetadata(run.sessionRef.metadata ?? {}),
summary: existing?.summary ?? {},
lastActivityAt: at,
createdAt: existing?.createdAt ?? at,
updatedAt: at,
expiresAt: run.sessionRef.expiresAt ?? existing?.expiresAt ?? null,
};
this.sessions.set(record.sessionId, record);
const nextSessionRef = sessionRefFromRecord(record, run.sessionRef);
this.updateRun(run.id, { sessionRef: nextSessionRef });
this.appendEvent(run.id, "backend_status", { phase: "session-updated", sessionRef: summarizeSessionRef(nextSessionRef), turnId });
}
private touchSessionForRun(run: RunRecord, patch: Partial<SessionRecord>, options: { bumpVersion: boolean; at?: string }): void {
const sessionId = run.sessionRef?.sessionId;
if (!sessionId) return;
const existing = this.sessions.get(sessionId);
if (!existing) return;
const at = options.at ?? nowIso();
const next: SessionRecord = { ...existing, ...patch, version: options.bumpVersion ? this.nextSessionVersion() : existing.version, updatedAt: at, lastActivityAt: patch.lastActivityAt ?? at };
this.sessions.set(sessionId, next);
}
private resolveSessionRunId(sessionId: string, requestedRunId: string | null): string | null {
const session = this.getSession(sessionId);
if (!session) throw new AgentRunError("schema-invalid", `session ${sessionId} was not found`, { httpStatus: 404 });
if (requestedRunId) {
const run = this.getRun(requestedRunId);
if (run.sessionRef?.sessionId !== sessionId) throw new AgentRunError("schema-invalid", `run ${requestedRunId} does not belong to session ${sessionId}`, { httpStatus: 404 });
return requestedRunId;
}
return session.activeRunId ?? session.lastRunId;
}
private nextSessionVersion(): number {
this.sessionVersion += 1;
return this.sessionVersion;
}
}
export function assertSessionBoundary(existing: SessionRecord, input: CreateRunInput): void {
if (existing.tenantId !== input.tenantId || existing.projectId !== input.projectId) {
throw new AgentRunError("tenant-policy-denied", "sessionRef cannot be reused across tenant or project boundary", { httpStatus: 403, details: { sessionId: existing.sessionId, valuesPrinted: false } });
}
// backendProfile is run-scoped, not session/PVC-scoped. A HWLAB session must be
// able to switch providers without losing its session storage or workspace.
}
export function statusFromTerminal(terminalStatus: TerminalStatus): RunRecord["status"] {
if (terminalStatus === "completed") return "completed";
if (terminalStatus === "cancelled") return "cancelled";
if (terminalStatus === "blocked") return "blocked";
return "failed";
}
export function commandStateFromTerminal(terminalStatus: TerminalStatus): CommandRecord["state"] {
if (terminalStatus === "completed") return "completed";
if (terminalStatus === "cancelled") return "cancelled";
return "failed";
}
export function isTerminalRunStatus(status: RunRecord["status"]): boolean {
return status === "completed" || status === "failed" || status === "blocked" || status === "cancelled";
}
export function isTerminalCommandState(state: CommandRecord["state"]): boolean {
return state === "completed" || state === "failed" || state === "cancelled";
}
export function cancelStagePayload(cancel: CancelRequestRecord, stage: CancelStage, extra: JsonRecord = {}): JsonRecord {
return {
phase: cancelPhase(stage),
cancelStage: stage,
cancelRequestId: cancel.id,
targetKind: cancel.targetKind,
targetId: cancel.targetId,
runId: cancel.runId,
commandId: cancel.commandId,
sessionId: cancel.sessionId,
taskId: cancel.taskId,
reason: cancel.reason,
requestedBy: cancel.requestedBy,
cancelEpoch: cancel.epoch,
...extra,
};
}
export function fenceLateEventForCancelledRun(run: RunRecord, type: RunEvent["type"], payload: JsonRecord): { type: RunEvent["type"]; payload: JsonRecord } {
if (run.status !== "cancelled" || isAllowedAfterCancel(type, payload)) return { type, payload };
return { type: "backend_status", payload: lateWriteRejectedPayload(run, null, { eventType: type, payload }) };
}
export function lateWriteRejectedPayload(run: RunRecord, command: CommandRecord | null, details: JsonRecord): JsonRecord {
return {
phase: "late-write-rejected",
cancelStage: "late-write-rejected",
cancelRequestId: command?.cancelRequestId ?? run.cancelRequestId,
cancelEpoch: command?.cancelEpoch ?? run.cancelEpoch,
runId: run.id,
commandId: command?.id ?? null,
reason: command?.cancelReason ?? run.cancelReason,
rejected: redactJson(details),
valuesPrinted: false,
};
}
function cancelPhase(stage: CancelStage): string {
return stage === "late-write-rejected" ? "late-write-rejected" : `cancel-${stage}`;
}
function isAllowedAfterCancel(type: RunEvent["type"], payload: JsonRecord): boolean {
if (type === "terminal_status" && payload.terminalStatus === "cancelled") return true;
const phase = typeof payload.phase === "string" ? payload.phase : "";
if (phase.startsWith("cancel-") || phase === "late-write-rejected" || phase === "turn-cancelled") return true;
if (phase === "command-terminal" && payload.failureKind === "cancelled") return true;
return false;
}
export function isTerminalQueueTaskState(state: QueueTaskState): boolean {
return state === "completed" || state === "failed" || state === "blocked" || state === "cancelled";
}
export function isLeaseExpired(leaseExpiresAt: string | null): boolean {
if (!leaseExpiresAt) return true;
return new Date(leaseExpiresAt).getTime() <= Date.now();
}
export function sessionRefFromRecord(record: SessionRecord, fallback: SessionRef): SessionRef {
return {
sessionId: record.sessionId,
...(record.conversationId ? { conversationId: record.conversationId } : fallback.conversationId ? { conversationId: fallback.conversationId } : {}),
...(record.threadId ? { threadId: record.threadId } : fallback.threadId ? { threadId: fallback.threadId } : {}),
...(record.expiresAt ? { expiresAt: record.expiresAt } : fallback.expiresAt ? { expiresAt: fallback.expiresAt } : {}),
...(Object.keys(record.metadata).length > 0 ? { metadata: record.metadata } : fallback.metadata ? { metadata: fallback.metadata } : {}),
};
}
export function buildSessionSummary(record: SessionRecord, readerId: string | null, readCursor: SessionReadCursorRecord | null): SessionSummary {
const active = record.executionState === "running" || record.activeRunId !== null || record.activeCommandId !== null;
const unread = !active && (!readCursor || readCursor.sessionVersion < record.version);
const attentionState = active ? "active" : unread ? "unread" : "read";
return { ...record, sessionPath: `${record.tenantId}/${record.projectId}/${record.sessionId}`, readerId, readCursor, attentionState, unread, active };
}
export function buildQueueTaskSummary(record: QueueTaskRecord, readerId: string | null, readCursor: QueueReadCursorRecord | null): QueueTaskSummary {
const active = !isTerminalQueueTaskState(record.state);
const unread = !active && (!readCursor || readCursor.taskVersion < record.version);
const attentionState = active ? "active" : unread ? "unread" : "read";
return { ...record, readerId, readCursor, attentionState, unread, active };
}
export function queueTaskMatchesCommander(task: QueueTaskSummary, readerId: string | null): boolean {
if (!readerId) return true;
return task.active || task.unread;
}
export function sessionMatchesListState(session: SessionSummary, state: SessionListState): boolean {
if (state === "all") return true;
if (state === "default") return session.active || session.unread;
if (state === "running") return session.active;
if (state === "unread") return session.unread;
if (state === "terminal") return session.executionState === "terminal";
if (state === "idle") return session.executionState === "idle";
return false;
}
export function sessionSort(a: SessionSummary, b: SessionSummary): number {
if (a.active !== b.active) return a.active ? -1 : 1;
if (a.unread !== b.unread) return a.unread ? -1 : 1;
return (b.lastActivityAt ?? b.updatedAt).localeCompare(a.lastActivityAt ?? a.updatedAt) || b.updatedAt.localeCompare(a.updatedAt) || a.sessionId.localeCompare(b.sessionId);
}
export function clampSessionLimit(limit: number): number {
return Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
}
export function parseSessionCursor(cursor: string | undefined): number | null {
if (!cursor) return null;
const value = Number(cursor);
return Number.isInteger(value) && value >= 0 ? value : null;
}
export function sessionListFilters(input: ListSessionsInput): JsonRecord {
return { state: input.state ?? "default", backendProfile: input.backendProfile ?? null, readerId: input.readerId ?? null, cursor: input.cursor ?? null, limit: clampSessionLimit(input.limit) };
}
export function summarizeSessionRef(sessionRef: SessionRef | null): JsonRecord | null {
if (!sessionRef) return null;
return {
sessionId: sessionRef.sessionId,
conversationId: sessionRef.conversationId ?? null,
threadId: sessionRef.threadId ?? null,
expiresAt: sessionRef.expiresAt ?? null,
metadataKeys: Object.keys(sessionRef.metadata ?? {}).sort(),
valuesPrinted: false,
};
}
export function runCreatedEventPayload(run: RunRecord): JsonRecord {
return {
phase: "run-created",
backendProfile: run.backendProfile,
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null),
};
}
export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourceBundleRef"] | null | undefined): JsonRecord | null {
if (!resourceBundleRef) return null;
return {
kind: resourceBundleRef.kind,
repoUrl: resourceBundleRef.repoUrl,
commitId: resourceBundleRef.commitId ?? null,
ref: resourceBundleRef.ref ?? null,
bundles: {
count: resourceBundleRef.bundles.length,
items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, ref: item.ref ?? resourceBundleRef.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
requiredSkills: resourceBundleRef.requiredSkills ? { count: resourceBundleRef.requiredSkills.length, names: resourceBundleRef.requiredSkills.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
submodules: resourceBundleRef.submodules ?? false,
lfs: resourceBundleRef.lfs ?? false,
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
};
}
export function queueTaskSort(a: QueueTaskRecord, b: QueueTaskRecord): number {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
}
export function queueTaskPayloadHash(input: CreateQueueTaskInput): string {
return stableHash({
tenantId: input.tenantId,
projectId: input.projectId,
queue: input.queue,
lane: input.lane,
title: input.title,
priority: input.priority,
backendProfile: input.backendProfile,
providerId: input.providerId,
workspaceRef: input.workspaceRef,
sessionRef: input.sessionRef,
executionPolicy: input.executionPolicy,
resourceBundleRef: input.resourceBundleRef,
payload: input.payload,
references: input.references,
metadata: input.metadata,
});
}
export function assertRunCreateReplay(existing: RunRecord, input: CreateRunInput): void {
const existingContract = {
tenantId: existing.tenantId,
projectId: existing.projectId,
workspaceRef: existing.workspaceRef,
sessionId: existing.sessionRef?.sessionId ?? null,
resourceBundleRef: existing.resourceBundleRef,
providerId: existing.providerId,
backendProfile: existing.backendProfile,
executionPolicy: existing.executionPolicy,
traceSink: existing.traceSink,
};
const inputContract = {
tenantId: input.tenantId,
projectId: input.projectId,
workspaceRef: input.workspaceRef,
sessionId: input.sessionRef?.sessionId ?? null,
resourceBundleRef: input.resourceBundleRef ?? null,
providerId: input.providerId,
backendProfile: input.backendProfile,
executionPolicy: input.executionPolicy,
traceSink: input.traceSink,
};
if (stableHash(existingContract) !== stableHash(inputContract)) {
throw new AgentRunError("schema-invalid", `run identity ${existing.id} reused with a different creation contract`, { httpStatus: 409 });
}
}
export function assertSessionTurnAdmissionContract(input: SessionTurnAdmissionInput): void {
if (!input.sessionId.trim()) throw new AgentRunError("schema-invalid", "session turn admission requires a non-empty sessionId", { httpStatus: 400 });
if (input.run.sessionRef?.sessionId !== input.sessionId) {
throw new AgentRunError("schema-invalid", "session turn admission run must reference the target session", {
httpStatus: 400,
details: { sessionId: input.sessionId, runSessionId: input.run.sessionRef?.sessionId ?? null, valuesPrinted: false },
});
}
if (input.command.type !== "turn") throw new AgentRunError("schema-invalid", "session turn admission only accepts turn commands", { httpStatus: 400 });
}
export function assertSessionCommandReplay(existing: CommandRecord, input: CreateCommandInput): void {
if (existing.type !== input.type || existing.payloadHash !== stableHash(input.payload) || (existing.idempotencyKey ?? null) !== (input.idempotencyKey ?? null)) {
throw new AgentRunError("schema-invalid", "session turn admission replay does not match the existing command", {
httpStatus: 409,
details: { runId: existing.runId, commandId: existing.id, reason: "session-turn-admission-replay-conflict", valuesPrinted: false },
});
}
}
export function activeSessionAdmissionConflict(sessionId: string, run: RunRecord, command: CommandRecord): AgentRunError {
return new AgentRunError("runner-lease-conflict", `session ${sessionId} already has an active turn admission`, {
httpStatus: 409,
details: {
reason: "session-turn-admission-active",
sessionId,
runId: run.id,
commandId: command.id,
runStatus: run.status,
commandState: command.state,
valuesPrinted: false,
},
});
}
export function invalidSessionAdmissionProjection(sessionId: string): AgentRunError {
return new AgentRunError("infra-failed", `session ${sessionId} active admission projection is inconsistent`, {
httpStatus: 503,
details: { reason: "session-turn-admission-projection-inconsistent", sessionId, valuesPrinted: false },
});
}
export function assertQueueTaskPayloadHash(task: QueueTaskRecord): void {
if (queueTaskPayloadHash(task) !== task.payloadHash) throw new AgentRunError("schema-invalid", `queue task ${task.id} payloadHash does not match its immutable contract`, { httpStatus: 409 });
}
export function assertQueueRetryable(task: QueueTaskRecord, attempts: QueueAttemptRecord[]): void {
if (task.state !== "failed" && task.state !== "blocked") throw queueRetryStateError(task);
if (!task.workspaceRef) throw new AgentRunError("schema-invalid", "queue retry requires the original workspaceRef", { httpStatus: 400 });
if (!task.providerId) throw new AgentRunError("schema-invalid", "queue retry requires the original providerId", { httpStatus: 400 });
if (!task.executionPolicy) throw new AgentRunError("schema-invalid", "queue retry requires the original executionPolicy", { httpStatus: 400 });
const active = attempts.find((attempt) => attempt.state === "reserved" || attempt.state === "running");
if (active) throw new AgentRunError("schema-invalid", `queue task ${task.id} already has an active retry attempt: ${active.attemptId}`, { httpStatus: 409, details: { attemptId: active.attemptId, state: active.state, valuesPrinted: false } });
}
export function queueRetryStateError(task: QueueTaskRecord): AgentRunError {
const failureKind = task.state === "cancelled" ? "cancelled" : "schema-invalid";
return new AgentRunError(failureKind, `queue task ${task.id} cannot be retried from state ${task.state}`, { httpStatus: 409, details: { allowedStates: ["failed", "blocked"], state: task.state, valuesPrinted: false } });
}
export function queueRetryReservation(task: QueueTaskRecord, attempt: QueueAttemptRecord | null, attempts: QueueAttemptRecord[], mutation: boolean, idempotentReplay: boolean, plannedRetryIndex?: number): QueueRetryReservation {
const retryIndex = attempt?.retryIndex ?? plannedRetryIndex ?? nextQueueRetryIndex(attempts);
const previousAttempt = attempt?.previousAttemptId
? attempts.find((item) => item.attemptId === attempt.previousAttemptId) ?? null
: latestQueueAttempt(attempts);
return {
action: "queue-retry-reservation",
mutation,
idempotentReplay,
task,
attempt,
previousAttempt,
retryIndex,
allowedTransition: {
from: task.state,
to: attempt && attempt.state !== "reserved" ? attempt.state : "running",
retryIndex,
previousAttemptId: attempt?.previousAttemptId ?? previousAttempt?.attemptId ?? task.latestAttempt?.attemptId ?? null,
payloadHash: task.payloadHash,
valuesPrinted: false,
},
};
}
export function latestQueueAttempt(attempts: QueueAttemptRecord[]): QueueAttemptRecord | null {
return attempts.reduce<QueueAttemptRecord | null>((latest, attempt) => !latest || attempt.retryIndex > latest.retryIndex ? attempt : latest, null);
}
export function nextQueueRetryIndex(attempts: QueueAttemptRecord[]): number {
return (latestQueueAttempt(attempts)?.retryIndex ?? -1) + 1;
}
export function queueAttemptRef(attempt: QueueAttemptRecord): QueueAttemptRef {
if (attempt.state === "reserved" || attempt.state === "pending") throw new AgentRunError("schema-invalid", `queue attempt ${attempt.attemptId} is not active`, { httpStatus: 409 });
return {
attemptId: attempt.attemptId,
state: attempt.state,
runId: attempt.runId,
commandId: attempt.commandId,
runnerJobId: attempt.runnerJobId,
sessionId: attempt.sessionId,
sessionPath: attempt.sessionPath,
failureKind: attempt.failureKind,
failureMessage: attempt.failureMessage,
};
}
export function assertQueueAttemptActivationReplay(attempt: QueueAttemptRecord, input: ActivateQueueRetryAttemptInput): void {
const expected = {
state: input.state,
commandId: input.commandId,
sessionId: input.sessionId,
sessionPath: input.sessionPath,
failureKind: input.failureKind,
failureMessage: input.failureMessage,
};
const actual = {
state: attempt.state,
commandId: attempt.commandId,
sessionId: attempt.sessionId,
sessionPath: attempt.sessionPath,
failureKind: attempt.failureKind,
failureMessage: attempt.failureMessage,
};
if (stableHash(actual) !== stableHash(expected)) throw new AgentRunError("schema-invalid", `queue attempt ${attempt.attemptId} activation replay does not match its immutable execution identity`, { httpStatus: 409 });
}
export function isTerminalQueueAttemptState(state: QueueAttemptRecord["state"]): boolean {
return state === "completed" || state === "failed" || state === "blocked" || state === "cancelled";
}
export function buildQueueStats(tasks: QueueTaskRecord[], queue: string | null, generatedAt = nowIso()): QueueStats {
const byState: Record<string, number> = {};
const byLane: Record<string, number> = {};
let maxVersion = 0;
for (const task of tasks) {
byState[task.state] = (byState[task.state] ?? 0) + 1;
byLane[task.lane] = (byLane[task.lane] ?? 0) + 1;
maxVersion = Math.max(maxVersion, task.version);
}
return { queue, total: tasks.length, byState, byLane, maxVersion, generatedAt };
}
export function clampQueueLimit(limit: number): number {
return Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
}
export function parseQueueCursor(cursor: string | undefined): number | null {
if (!cursor) return null;
const value = Number(cursor);
return Number.isInteger(value) && value >= 0 ? value : null;
}
function queueReadKey(taskId: string, readerId: string): string {
return `${taskId}:${readerId}`;
}
function queueAttemptKey(taskId: string, attemptId: string): string {
return `${taskId}:${attemptId}`;
}
export function sessionReadKey(sessionId: string, readerId: string): string {
return `${sessionId}:${readerId}`;
}
export function titleFromMetadata(metadata: JsonRecord): string | null {
const title = metadata.title;
return typeof title === "string" && title.trim().length > 0 ? title.trim().slice(0, 200) : null;
}
export function sessionTitleFromCommand(command: CommandRecord): string | null {
const value = command.payload.prompt;
if (typeof value !== "string") return null;
return value.trim().replace(/\s+/gu, " ").slice(0, 120) || null;
}
function isRunCreatedEvent(event: RunEvent): boolean {
return event.type === "backend_status" && event.payload.phase === "run-created";
}
function eventMatchesCommand(event: RunEvent, commandId: string): boolean {
const payload = event.payload;
return payload.commandId === commandId || payload.targetCommandId === commandId || (event.type === "terminal_status" && payload.commandId === undefined);
}
function commandForEvent(commands: Map<string, CommandRecord>, event: RunEvent): CommandRecord | null {
for (const candidate of [event.payload.commandId, event.payload.targetCommandId]) {
if (typeof candidate === "string") {
const command = commands.get(candidate);
if (command) return command;
}
}
return null;
}
function assertMemoryClaim(record: DurableQueueClaim & { leaseExpiresAt?: string | null }, claim: DurableQueueClaim, component: string, stateMatches: boolean): void {
const leaseExpiresAt = Date.parse(record.leaseExpiresAt ?? "");
if (!stateMatches || !claim.leaseOwner || record.leaseOwner !== claim.leaseOwner || record.attemptCount !== claim.attemptCount || !Number.isFinite(leaseExpiresAt) || leaseExpiresAt <= Date.now()) {
throw staleClaimError(component, claim);
}
}
function staleClaimError(component: string, claim: DurableQueueClaim): AgentRunError {
return new AgentRunError("runner-lease-conflict", `${component} claim is stale`, { httpStatus: 409, details: { id: claim.id, attemptCount: claim.attemptCount, valuesPrinted: false } });
}
function runnerRetentionFenceConflict(runId: string): AgentRunError {
return new AgentRunError("runner-lease-conflict", `run ${runId} is fenced by runner retention`, {
httpStatus: 409,
details: { reason: "runner-retention-db-fenced", runId, valuesPrinted: false },
});
}
function isActiveRunnerDispatchIntent(intent: RunnerDispatchIntentRecord): boolean {
return intent.state === "pending" || intent.state === "dispatching" || intent.state === "retry";
}
function workQueueStatus<T extends JsonRecord>(items: T[], component: string, completed: (item: T) => boolean = (item) => ["dispatched", "failed", "cancelled"].includes(String(item.state ?? ""))): JsonRecord {
const pending = items.filter((item) => !completed(item));
const retrying = pending.filter((item) => Number(item.attemptCount ?? 0) > 0);
const oldest = pending.slice().sort((a, b) => String(a.createdAt ?? "").localeCompare(String(b.createdAt ?? "")))[0];
return {
component,
totalCount: items.length,
backlogCount: pending.length,
retryingCount: retrying.length,
oldestPendingAt: typeof oldest?.createdAt === "string" ? oldest.createdAt : null,
maxAttemptCount: items.reduce((max, item) => Math.max(max, Number(item.attemptCount ?? 0)), 0),
valuesPrinted: false,
};
}
export function isSessionOutputEvent(event: RunEvent): boolean {
return event.type === "assistant_progress" || event.type === "assistant_message" || event.type === "command_output" || event.type === "diff" || event.type === "error" || event.type === "terminal_status";
}