Before this commit `createDocumentWithLifecycle` enqueued ledger rows
inside the save transaction but consumed them from an in-memory
`pendingSideEffects` array straight after commit. A crash between the
commit and the drain stranded rows as `pending` forever; a crash mid
handler left them as `running`; and there was no retry budget.
The new `DocumentLifecycleProcessor` is the single drainer. It:
- claims work via `claimPendingBatch`, an atomic `UPDATE … RETURNING`
that flips rows from `pending` to `running` while incrementing
`attempts`, so two drainers cannot race onto the same row;
- dispatches each row through a handler registered by `hookType`,
parsing the frozen `payload_json` back into the call arguments;
- records `succeeded` / `failed` / `retryable` outcomes through the
ledger, so a transient failure stays on the queue until the attempt
cap expires, after which it is marked `failed` for operators;
- reclaims rows stuck in `running` past `staleRunningMs` back to
`pending` on every sweep tick plus at startup, so a crashed process
never leaves a row orphaned;
- runs a periodic sweep so every document — including those whose
immediate post-commit drain failed — is eventually advanced.
`createDocumentWithLifecycle` now only enqueues and then calls
`lifecycleProcessor.drainForDocument` for the immediate happy-path pass.
Handlers live in `document-lifecycle-handlers.ts` so the processor stays
free of service-graph knowledge. The processor is constructed in
`bootstrap-application`, started there, and stopped on `before-quit`.
142 lines
6.9 KiB
TypeScript
142 lines
6.9 KiB
TypeScript
import log from "electron-log/main.js";
|
|
import type { AgentApiServer } from "../api-server";
|
|
import type { AppDatabase } from "../database";
|
|
import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types";
|
|
import type { NotificationOptions } from "../notification-service";
|
|
import type { createServiceGraph } from "../service-graph";
|
|
import type { createThreadIntegration } from "../thread-integration";
|
|
import type { createCheckpointEngine } from "../checkpoint-engine";
|
|
import type { AutomationEventContext } from "../automation/types";
|
|
import {
|
|
createDocumentWithLifecycle,
|
|
type DocumentLifecycleInput,
|
|
type DocumentLifecycleOutcome,
|
|
} from "../document-lifecycle";
|
|
import type { DocumentLifecycleProcessor } from "../document-lifecycle-processor";
|
|
|
|
type ServiceGraph = ReturnType<typeof createServiceGraph>;
|
|
type ThreadIntegration = ReturnType<typeof createThreadIntegration>;
|
|
type CheckpointEngine = ReturnType<typeof createCheckpointEngine>;
|
|
|
|
export interface CreateApiServerFn {
|
|
(db: AppDatabase, callbacks: {
|
|
onDomainChanged: () => void;
|
|
onWakeAgent: (agentId: string, companyId: string, trigger: string) => string | null;
|
|
onTaskCreated: (taskId: string, companyId: string) => void;
|
|
onTaskStatusChanged: (taskId: string, companyId: string, prev: string, next: string) => void;
|
|
onCommentPosted: (taskId: string, companyId: string, authorAgentId: string | null) => void;
|
|
onApprovalCreated: (approvalId: string, companyId: string, taskId: string | null, approvalType: string, summary: string) => void;
|
|
onApprovalResolved: (approvalId: string, companyId: string, decision: string) => void;
|
|
createDocument: (input: DocumentLifecycleInput) => DocumentLifecycleOutcome;
|
|
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
|
|
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
|
|
injectRuntimeMessage: (recipientAgentId: string, companyId: string, message: AgentMessageRecord) => boolean;
|
|
emitEvent: (event: DesktopEvent) => void;
|
|
notify: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
|
|
}, allowedOrigins: string[]): AgentApiServer;
|
|
}
|
|
|
|
export interface WireApiServerDeps {
|
|
db: AppDatabase;
|
|
serviceGraph: ServiceGraph;
|
|
threadIntegration: ThreadIntegration;
|
|
checkpointEngine: CheckpointEngine;
|
|
lifecycleProcessor: DocumentLifecycleProcessor;
|
|
emitEvent: (event: DesktopEvent) => void;
|
|
notify: (options: NotificationOptions) => void;
|
|
publishDomainChanged: () => void;
|
|
isDev: boolean;
|
|
devServerUrl?: string;
|
|
createApiServer: CreateApiServerFn;
|
|
}
|
|
|
|
export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
|
|
const wakeAgentIfPossible = (agentId: string | null | undefined, companyId: string, trigger: string) => {
|
|
const orchestration = deps.serviceGraph.getOrchestrationService();
|
|
if (!orchestration) throw new Error("Orchestration service is not initialized.");
|
|
return orchestration.wakeAgentIfPossible(agentId, companyId, trigger);
|
|
};
|
|
const dispatchAutomation = (trigger: string, ctx: AutomationEventContext) => {
|
|
const automation = deps.serviceGraph.getAutomationService();
|
|
if (!automation) throw new Error("Automation service is not initialized.");
|
|
automation.dispatchAutomation(trigger, ctx);
|
|
};
|
|
|
|
const onTaskCreated = (taskId: string, companyId: string) => {
|
|
const automation = deps.serviceGraph.getAutomationService();
|
|
if (!automation) throw new Error("Automation service is not initialized.");
|
|
automation.handleTaskCreated(taskId, companyId);
|
|
deps.threadIntegration.onTaskCreated(taskId, companyId);
|
|
};
|
|
const onTaskStatusChanged = (taskId: string, companyId: string, prev: string, next: string) => {
|
|
const automation = deps.serviceGraph.getAutomationService();
|
|
if (!automation) throw new Error("Automation service is not initialized.");
|
|
automation.handleTaskStatusChange(taskId, companyId, prev, next);
|
|
deps.threadIntegration.onTaskStatusChanged(taskId, companyId, prev, next);
|
|
if (prev === "todo" && next === "in_progress") {
|
|
try {
|
|
const task = deps.db.getTask(taskId);
|
|
deps.checkpointEngine.onTaskStarted(taskId, companyId, task.assigneeAgentId);
|
|
} catch (error) {
|
|
log.warn(
|
|
`[wire-api-server] checkpointEngine.onTaskStarted failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
const onApprovalResolved = (approvalId: string, companyId: string, decision: string) => {
|
|
const automation = deps.serviceGraph.getAutomationService();
|
|
if (!automation) throw new Error("Automation service is not initialized.");
|
|
automation.handleApprovalResolved(approvalId, companyId, decision);
|
|
deps.threadIntegration.onApprovalResolved(approvalId, companyId, decision);
|
|
};
|
|
|
|
const allowedOrigins = [deps.isDev && deps.devServerUrl ? new URL(deps.devServerUrl).origin : null, "app://-"]
|
|
.filter((origin): origin is string => Boolean(origin));
|
|
|
|
return deps.createApiServer(deps.db, {
|
|
onDomainChanged: deps.publishDomainChanged,
|
|
onWakeAgent: (agentId, companyId, trigger) => wakeAgentIfPossible(agentId, companyId, trigger),
|
|
onTaskCreated,
|
|
onTaskStatusChanged,
|
|
onCommentPosted: (taskId, companyId, authorAgentId) => {
|
|
dispatchAutomation("comment_posted", { companyId, taskId, agentId: authorAgentId ?? undefined });
|
|
},
|
|
onApprovalCreated: (approvalId, companyId, taskId, approvalType, summary) => {
|
|
deps.threadIntegration.onApprovalCreated(approvalId, companyId, taskId, approvalType, summary);
|
|
},
|
|
onApprovalResolved,
|
|
createDocument: (input) =>
|
|
createDocumentWithLifecycle(
|
|
{
|
|
db: deps.db,
|
|
threadIntegration: deps.threadIntegration,
|
|
checkpointEngine: deps.checkpointEngine,
|
|
lifecycleProcessor: deps.lifecycleProcessor,
|
|
publishDomainChanged: deps.publishDomainChanged,
|
|
logger: log,
|
|
},
|
|
input,
|
|
),
|
|
onAutoAssignTask: (taskId, companyId) => deps.db.autoAssignTask(taskId, companyId),
|
|
onHumanMessage: (companyId, toAgentId, threadId, waitStateId) => {
|
|
deps.threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId);
|
|
deps.publishDomainChanged();
|
|
},
|
|
injectRuntimeMessage: (recipientAgentId, companyId, message) => {
|
|
const injector = deps.serviceGraph.getRuntimeMessageInjector();
|
|
if (!injector) return false;
|
|
try {
|
|
return injector.tryInject(recipientAgentId, companyId, message);
|
|
} catch (error) {
|
|
log.warn(
|
|
`[wire-api-server] runtime message injection failed for agent ${recipientAgentId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
emitEvent: deps.emitEvent,
|
|
notify: deps.notify,
|
|
}, allowedOrigins);
|
|
}
|