fix(server): settle routine status from committed work

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 16:01:08 -05:00
1 parent 76fde891cc
commit dd49318d55
15 files changed
+475 -54

No files matched your search

@@ -0,0 +1,54 @@
# Routine execution status settlement
The Cloud routine status worker repairs a routine invocation when its linked
execution task and saved routine status disagree. The previous heartbeat callback
scanned every five minutes, even with no routines or only completed history.
## Ownership and trigger
`routine-run-status.ts` owns the projection, custody checks and repair transaction.
The worker loads actual mismatches at startup. It accepts issue hints from issue
creation, status/origin changes, checkout/release, routine issue association,
execution control writes and review state writes. It uses the existing
transaction-aware work notification protocol. An outer transaction owns the hint;
a savepoint release does not wake the repair worker before commit.
A standard execution must name the same routine, invocation, company and linked
task. Plugin-managed executions may use their declared custom origin. Repair
locks the routine and task and rechecks the linked invocation before writing.
It preserves skipped/coalesced history and unrelated dispatch failures. A task
that resumes clears an execution-task transient failure through the same owner
used by native completion and issue routes.
`activeIssue` remains derived from open tasks and live heartbeat runs. A terminal
heartbeat with an open task does not create a completion obligation. The worker
does not retry merely because a run is active or a routine exists.
## Lifecycle and writer contract
`registerScopedDeadlineWork` owns coalescing, fair batches, failure backoff, drain
and shutdown. A failed owner retries with bounded backoff. Successful settlement
removes that owner. Settled configuration, healthy active tasks and completed
history leave no recurring worker SQL. The existing Cloud and heartbeat scheduler
enablement gates remain in place. Ordinary routine scheduling is unchanged.
Supported app writers register `notifyRoutineExecutionStatus(tx, issueId)` before
their mutation. Common execution writers without an issue hint request debt
reconstruction. Backups and independent SQL import/repair tools must restore
before startup or call that same transaction contract; process-local hints do
not observe independent SQL or another process. No permanent listener or periodic
configured-routine scan is added.
The in-process retry deadline does not establish external Cloud wake coverage.
This change preserves the existing host sleep safety checks and does not claim
measured host or database suspension.
## Acceptance
Tests exercise ordinary issue writes and a real heartbeat lifecycle transition
against PostgreSQL with the production coordinator. Provider wakeup is simulated.
They inspect durable routine rows and the normal routine detail read separately.
Coverage includes empty/configured/history quiet periods, first producer,
terminal outcomes and reopen, legacy mismatch reconstruction, skipped/coalesced
custody, outer commit/rollback, retry/restart, drain/resume and standby/disable.
Focused worker tests exercise in-flight invalidations, retry isolation and stop.
@@ -0,0 +1,206 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issues, pluginManagedResources, plugins, routineRuns, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { notifyRoutineExecutionStatus } from "../services/routine-execution-status-notifications.js";
import { registerRoutineExecutionStatusWork } from "../services/routine-execution-status-work.js";
import { routineExecutionStatusService } from "../services/routine-run-status.js";
import { routineService } from "../services/routines.js";
import { issueService } from "../services/issues.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "../services/heartbeat/run-lifecycle.js";
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
import { createHeartbeatRunPreparation } from "../services/heartbeat/run-preparation.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_ROUTINE_STATUS_WORK_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("routine execution status work with PostgreSQL", () => {
let db: Db, temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let enabled = true, draining = false, standby = false;
const errors: unknown[] = [];
beforeAll(async () => {
if (!external) temporary = await startEmbeddedPostgresTestDatabase("routine-status-work-");
db = createDb(external ?? temporary!.connectionString, { maxConnections: 6 });
}, 60_000);
beforeEach(() => {
enabled = true; draining = false; standby = false; errors.length = 0;
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
});
afterEach(async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.execute(sql`SET client_min_messages = warning; TRUNCATE companies CASCADE`);
});
afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); });
const canRun = () => enabled && !draining && !standby;
async function start(service = routineExecutionStatusService(db)) {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun, canReconcile: () => !standby,
onError: error => errors.push(error) });
const worker = registerRoutineExecutionStatusWork(coordinator, service, canRun);
await worker.ready;
return worker;
}
async function settled() { await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0)); }
async function quiet() {
await settled();
const select = vi.spyOn(db, "select"), execute = vi.spyOn(db, "execute"), transaction = vi.spyOn(db, "transaction");
try {
await vi.advanceTimersByTimeAsync(20 * 60_000);
expect(select).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
} finally { select.mockRestore(); execute.mockRestore(); transaction.mockRestore(); }
}
async function fixture() {
const companyId = randomUUID(), agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Routine status work", issuePrefix: `R${companyId.slice(0, 6)}`,
defaultResponsibleUserId: randomUUID(), requireBoardApprovalForNewAgents: false });
await db.insert(agents).values({ id: agentId, companyId, name: "Routine worker", status: "active", lifecycleState: "ready", adapterType: "codex_local" });
const service = routineService(db, { heartbeat: { wakeup: async () => null }, runtimeEnv: {} });
const routine = await service.create(companyId, { title: "Status projection", assigneeAgentId: agentId,
priority: "medium", status: "active", concurrencyPolicy: "always_enqueue", catchUpPolicy: "skip_missed" }, {});
return { companyId, agentId, routine, service };
}
async function dispatch(f: Awaited<ReturnType<typeof fixture>>) {
return f.service.runRoutine(f.routine.id, { source: "manual", idempotencyKey: randomUUID() });
}
async function saved(id: string) { return (await db.select().from(routineRuns).where(eq(routineRuns.id, id)))[0]; }
async function projected(id: string, status: string) {
await vi.waitFor(async () => expect((await saved(id)).status).toBe(status));
await settled();
}
it("settles empty startup, configured idle, healthy active and retained terminal history without recurring SQL", async () => {
await start(); await quiet();
const f = await fixture(); await quiet();
const run = await dispatch(f); await quiet();
await issueService(db).update(run.linkedIssueId!, { status: "done" });
await projected(run.id, "completed");
const before = await saved(run.id); await quiet();
expect(await saved(run.id)).toEqual(before); expect(errors).toEqual([]);
});
it.each(["done", "blocked", "cancelled"])("reactivates from the first ordinary %s transition and preserves input", async status => {
await start(); await quiet();
const f = await fixture(), run = await dispatch(f);
await issueService(db).update(run.linkedIssueId!, { status });
await projected(run.id, status === "done" ? "completed" : "failed");
expect((await saved(run.id)).completedAt).toBeInstanceOf(Date);
await quiet(); expect(errors).toEqual([]);
if (status !== "done") {
await issueService(db).update(run.linkedIssueId!, { status: "in_progress" });
await projected(run.id, "issue_created");
expect(await saved(run.id)).toMatchObject({ completedAt: null, failureReason: null });
await quiet();
}
});
it("reconstructs legacy terminal debt and reopened transient failures but leaves skipped and coalesced history unchanged", async () => {
const f = await fixture(), first = await dispatch(f), second = await dispatch(f);
await db.update(issues).set({ status: "done" }).where(eq(issues.id, first.linkedIssueId!));
await db.update(routineRuns).set({ status: "failed", failureReason: "Execution issue moved to blocked", completedAt: new Date() }).where(eq(routineRuns.id, second.id));
const terminal = await db.insert(routineRuns).values(["skipped", "coalesced"].map(status => ({ companyId: f.companyId,
routineId: f.routine.id, source: "manual", linkedIssueId: first.linkedIssueId, status, completedAt: new Date() }))).returning();
await start(); await projected(first.id, "completed"); await projected(second.id, "issue_created");
for (const row of terminal) expect(await saved(row.id)).toEqual(row);
await quiet(); expect(errors).toEqual([]);
});
it("settles the declared custom origin of a real plugin-managed dispatch and preserves other companies", async () => {
const f = await fixture(), other = await fixture();
const pluginKey = `routine-status-${randomUUID()}`, originId = `operation:${randomUUID()}`;
const [plugin] = await db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0",
manifestJson: { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Routine test", description: "Routine test",
author: "Paperclip", categories: ["automation"], capabilities: ["routines.managed"], entrypoints: { worker: "./worker.js" } } }).returning();
await db.insert(pluginManagedResources).values({ companyId: f.companyId, pluginId: plugin.id, pluginKey,
resourceKind: "routine", resourceKey: "status", resourceId: f.routine.id, defaultsJson: { issueTemplate: { originId } } });
const run = await dispatch(f), otherRun = await dispatch(other);
expect((await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!)))[0].originId).toBe(originId);
await start(); await quiet();
await issueService(db).update(run.linkedIssueId!, { status: "done" }); await projected(run.id, "completed");
const before = await saved(run.id);
await db.transaction(async tx => { await notifyRoutineExecutionStatus(tx, run.linkedIssueId!); await notifyRoutineExecutionStatus(tx, run.linkedIssueId!); });
await settled(); await quiet();
expect(await saved(run.id)).toEqual(before); expect((await saved(otherRun.id)).status).toBe("issue_created"); expect(errors).toEqual([]);
});
it("waits for the real outer transaction and forgets a rolled-back nested status write", async () => {
const f = await fixture(), run = await dispatch(f); await start(); await quiet();
let release!: () => void, entered!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const written = new Promise<void>(resolve => { entered = resolve; });
const writing = db.transaction(async tx => {
await issueService(db).update(run.linkedIssueId!, { status: "done" }, tx);
entered(); await gate;
});
try { await written; expect((await saved(run.id)).status).toBe("issue_created"); }
finally { release(); await writing; }
await projected(run.id, "completed"); await quiet();
const other = await dispatch(f);
await expect(db.transaction(async tx => {
await issueService(db).update(other.linkedIssueId!, { status: "cancelled" }, tx);
throw new Error("outer rollback");
})).rejects.toThrow("outer rollback");
await settled(); expect((await saved(other.id)).status).toBe("issue_created"); await quiet(); expect(errors).toEqual([]);
});
it("keeps the first write during drain and reconstructs on resume without passive SQL", async () => {
const f = await fixture(), run = await dispatch(f); await start(); await quiet();
draining = true;
await issueService(db).update(run.linkedIssueId!, { status: "cancelled" });
await settled(); await quiet(); expect((await saved(run.id)).status).toBe("issue_created");
draining = false; await vi.advanceTimersByTimeAsync(30_000); await projected(run.id, "failed");
await quiet(); expect(errors).toEqual([]);
});
it.each(["standby", "disabled"])("does no owner SQL while %s and reconstructs when enabled", async mode => {
const f = await fixture(), run = await dispatch(f);
await issueService(db).update(run.linkedIssueId!, { status: "done" });
if (mode === "standby") standby = true; else enabled = false;
await start(); await quiet(); expect((await saved(run.id)).status).toBe("issue_created");
standby = false; enabled = true; await vi.advanceTimersByTimeAsync(30_000);
await projected(run.id, "completed"); await quiet(); expect(errors).toEqual([]);
});
it("retries failed exact owners, retains the retry across drain and reconstructs after restart", async () => {
const f = await fixture(), run = await dispatch(f), service = routineExecutionStatusService(db);
const settle = vi.spyOn(service, "settle").mockRejectedValueOnce(new Error("projection unavailable"));
await start(service);
await issueService(db).update(run.linkedIssueId!, { status: "done" });
await vi.waitFor(() => expect(errors).toHaveLength(1));
expect((await saved(run.id)).status).toBe("issue_created");
await vi.advanceTimersByTimeAsync(29_000); expect(settle).toHaveBeenCalledTimes(1);
draining = true; await vi.advanceTimersByTimeAsync(60_000); await quiet();
draining = false; await vi.advanceTimersByTimeAsync(30_000); await projected(run.id, "completed"); await quiet();
await coordinator!.stop(); coordinator = undefined;
const other = await dispatch(f); await issueService(db).update(other.linkedIssueId!, { status: "blocked" });
await start(); await projected(other.id, "failed"); await quiet();
});
it("preserves notifications during a sweep, coalesces duplicate hints and never overlaps", async () => {
const f = await fixture(), first = await dispatch(f), second = await dispatch(f), service = routineExecutionStatusService(db);
let entered!: () => void, release!: () => void;
const enteredGate = new Promise<void>(resolve => { entered = resolve; });
const releaseGate = new Promise<void>(resolve => { release = resolve; });
const load = service.load;
vi.spyOn(service, "load").mockImplementationOnce(async issueId => { const rows = await load(issueId); entered(); await releaseGate; return rows; });
const starting = start(service); await enteredGate;
await issueService(db).update(second.linkedIssueId!, { status: "done" });
await db.transaction(async tx => { await notifyRoutineExecutionStatus(tx, second.linkedIssueId!); await notifyRoutineExecutionStatus(tx, second.linkedIssueId!); });
release(); await starting;
await projected(second.id, "completed"); expect((await saved(first.id)).status).toBe("issue_created");
await quiet(); expect(errors).toEqual([]);
});
it.each(["succeeded", "failed", "cancelled", "interrupted", "timed_out"])("uses real %s run lifecycle changes for activeIssue without an open-issue retry", async status => {
const f = await fixture(), run = await dispatch(f); await start();
const [execution] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId,
status: "running", contextSnapshot: { issueId: run.linkedIssueId } }).returning();
await db.update(issues).set({ executionRunId: execution.id }).where(eq(issues.id, run.linkedIssueId!));
expect((await f.service.getDetail(f.routine.id))?.activeIssue?.id).toBe(run.linkedIssueId);
const deps = {
...createHeartbeatRunState(db), ...createHeartbeatRunPreparation(db),
issuesSvc: { addComment: vi.fn(), listReviewAttention: vi.fn() },
recovery: { escalateStrandedAssignedIssue: vi.fn() }, companySkills: { completeTestRunForIssue: vi.fn() },
budgets: { getInvocationBlock: vi.fn(async () => null) }, treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
enqueueWakeup: vi.fn(), getCurrentUserRedactionOptions: vi.fn(async () => ({})),
getAgentInvokability: vi.fn(async () => ({ invokable: true })), emitTerminalAgentTaskRun: vi.fn(), budgetHooks: {},
} satisfies HeartbeatLifecycleDependencies;
await createHeartbeatLifecycle(db, deps).setRunStatusIfRunning(execution.id, status, { finishedAt: new Date() });
expect((await f.service.getDetail(f.routine.id))?.activeIssue).toBeNull();
expect((await saved(run.id)).status).toBe("issue_created"); await quiet(); expect(errors).toEqual([]);
});
});
@@ -0,0 +1,86 @@
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { registerRoutineExecutionStatusWork } from "../services/routine-execution-status-work.js";
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator>;
let canRun = true;
const errors: unknown[] = [];
const entry = { id: "run", companyId: "company", routineId: "routine", issueId: "issue" };
beforeEach(() => {
vi.useFakeTimers(); canRun = true; errors.length = 0;
const owner = installDatabaseWorkSignals({ execute: vi.fn(async () => []) });
coordinator = createDeliveryWorkCoordinator({ owner, canRun: () => canRun, canReconcile: () => true,
onError: error => errors.push(error) });
});
afterEach(async () => { await coordinator.stop(); vi.useRealTimers(); });
it("loads actual debt once and leaves no recurring owner work", async () => {
const service = { load: vi.fn(async () => []), settle: vi.fn(async () => true) };
await registerRoutineExecutionStatusWork(coordinator, service, () => canRun).ready;
expect(service.load).toHaveBeenCalledExactlyOnceWith(undefined);
expect(coordinator.nextWakeAt()).toBeNull();
await vi.advanceTimersByTimeAsync(20 * 60_000);
expect(service.load).toHaveBeenCalledOnce(); expect(service.settle).not.toHaveBeenCalled(); expect(errors).toEqual([]);
});
it("reconstructs only an invalidated issue and coalesces duplicate requests", async () => {
const service = { load: vi.fn(async (_issueId?: string) => [] as typeof entry[]), settle: vi.fn(async () => true) };
const worker = registerRoutineExecutionStatusWork(coordinator, service, () => canRun); await worker.ready;
service.load.mockResolvedValueOnce([entry]);
worker.invalidate("issue"); worker.invalidate("issue");
await vi.advanceTimersByTimeAsync(1000);
expect(service.load).toHaveBeenLastCalledWith("issue"); expect(service.load).toHaveBeenCalledTimes(2);
expect(service.settle).toHaveBeenCalledExactlyOnceWith(expect.objectContaining(entry));
expect(coordinator.nextWakeAt()).toBeNull(); expect(errors).toEqual([]);
});
it("retries only the failed owner at backoff and disarms after success", async () => {
const service = { load: vi.fn(async () => [entry]), settle: vi.fn(async () => true).mockRejectedValueOnce(new Error("locked")) };
await registerRoutineExecutionStatusWork(coordinator, service, () => canRun).ready;
expect(errors).toHaveLength(1);
const retryAt = coordinator.nextWakeAt(); expect(retryAt).toBe(Date.now() + 30_000);
await vi.advanceTimersByTimeAsync(29_999); expect(service.settle).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1); expect(service.settle).toHaveBeenCalledTimes(2);
expect(service.load).toHaveBeenCalledOnce(); expect(coordinator.nextWakeAt()).toBeNull();
await vi.advanceTimersByTimeAsync(20 * 60_000); expect(service.settle).toHaveBeenCalledTimes(2);
});
it("retains startup and first work during drain without passive owner calls", async () => {
canRun = false;
const service = { load: vi.fn(async () => [entry]), settle: vi.fn(async () => true) };
const worker = registerRoutineExecutionStatusWork(coordinator, service, () => canRun); await worker.ready;
worker.invalidate("issue");
await vi.advanceTimersByTimeAsync(20 * 60_000);
expect(service.load).not.toHaveBeenCalled(); expect(service.settle).not.toHaveBeenCalled();
canRun = true; await vi.advanceTimersByTimeAsync(30_000);
expect(service.settle).toHaveBeenCalledOnce(); expect(coordinator.nextWakeAt()).toBeNull(); expect(errors).toEqual([]);
});
it("keeps a failed owner across drain and retries after resume", async () => {
const service = { load: vi.fn(async () => [entry]), settle: vi.fn(async () => true).mockRejectedValueOnce(new Error("write failed")) };
await registerRoutineExecutionStatusWork(coordinator, service, () => canRun).ready;
canRun = false; await vi.advanceTimersByTimeAsync(20 * 60_000);
expect(service.settle).toHaveBeenCalledOnce(); expect(service.load).toHaveBeenCalledOnce();
canRun = true; await vi.advanceTimersByTimeAsync(30_000);
expect(service.settle).toHaveBeenCalledTimes(2); expect(coordinator.nextWakeAt()).toBeNull();
});
it("retains a notification that arrives while the startup reconstruction is in flight", async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const service = { load: vi.fn(async (_issueId?: string) => [] as typeof entry[]), settle: vi.fn(async () => true) };
service.load.mockImplementationOnce(async () => { await gate; return []; });
const worker = registerRoutineExecutionStatusWork(coordinator, service, () => canRun);
await vi.waitFor(() => expect(service.load).toHaveBeenCalledOnce());
worker.invalidate("issue"); service.load.mockResolvedValueOnce([entry]); release(); await worker.ready;
await vi.advanceTimersByTimeAsync(1000);
expect(service.settle).toHaveBeenCalledOnce(); expect(coordinator.nextWakeAt()).toBeNull(); expect(errors).toEqual([]);
});
it("cancels retries and subscriptions at shutdown", async () => {
const service = { load: vi.fn(async () => [entry]), settle: vi.fn(async () => true).mockRejectedValue(new Error("offline")) };
const worker = registerRoutineExecutionStatusWork(coordinator, service, () => canRun); await worker.ready;
await worker.stop(); worker.invalidate("issue"); await vi.advanceTimersByTimeAsync(20 * 60_000);
expect(service.settle).toHaveBeenCalledOnce(); expect(coordinator.nextWakeAt()).toBeNull();
});
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -5,11 +6,12 @@ import { companies, createDb, issues, routineRuns, routines } from "@paperclipai
import { routineService } from "../services/routines.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_ROUTINE_STATUS_TEST_DATABASE_URL");
const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping routine status database tests: ${support.reason}`);
(support.supported ? describe : describe.skip)("routine status reconciliation", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("routine-status-"); db = createDb(database.connectionString); }, 60_000);
beforeAll(async () => { if (!external) database = await startEmbeddedPostgresTestDatabase("routine-status-"); db = createDb(external ?? database.connectionString, { maxConnections: 6 }); }, 60_000);
afterEach(async () => { vi.restoreAllMocks(); await db.execute(sql`SET client_min_messages = warning; TRUNCATE companies CASCADE`); });
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function seed(status = "blocked") {
+7 -10
View File
@@ -1,3 +1,5 @@
import { registerRoutineExecutionStatusWork } from "./services/routine-execution-status-work.js";
import { routineExecutionStatusService } from "./services/routine-run-status.js";
import { registerProviderTraceRetentionWork } from "./services/provider-trace-retention-work.js";
import { registerRunEvidenceMigrationWork } from "./services/run-evidence-migration-work.js";
import { registerSkillVersionRetentionWork } from "./services/skill-version-retention-work.js";
@@ -1560,15 +1562,6 @@ async function startServerWithDatabaseTeardown(
heartbeat.drainActiveRunExecutions();
prepareHotRestartShutdown = heartbeat.prepareHotRestartShutdown;
const routines = routineService(db as any, { pluginWorkerManager });
let nextRoutineStatusReconcile = 0;
const scheduleRoutineStatusReconcile = () => {
if (!isCloudManagedInstance() || isWarmStandby() || isIdleTaskDrainActive() || heartbeatSchedulerStopped ||
Date.now() < nextRoutineStatusReconcile) return;
nextRoutineStatusReconcile = Date.now() + 5 * 60_000;
trackHeartbeatSchedulerWork(routines.reconcileExecutionRunStatuses().then(result => {
if (result.settled > 0) logger.info(result, "reconciled routine execution issue status");
}).catch(() => logger.warn("routine execution status reconciliation deferred")));
};
const statusCards = statusCardService(db as any);
const issues = issueService(db as any);
const mergedPullRequestConfirmations = issueThreadInteractionService(db as any, {
@@ -1989,7 +1982,6 @@ async function startServerWithDatabaseTeardown(
// captures the authoritative set of running heartbeat rows.
trackHeartbeatSchedulerWork((async () => {
if (isIdleTaskDrainActive() || heartbeatSchedulerStopped) return;
scheduleRoutineStatusReconcile();
trackHeartbeatSchedulerWork(runRetentionSweep().catch((err: unknown) => {
logger.error({ err }, "decision retention sweep failed");
}));
@@ -2097,6 +2089,11 @@ async function startServerWithDatabaseTeardown(
}));
};
const startHeartbeatScheduler = () => {
if (isCloudManagedInstance()) {
const status = registerRoutineExecutionStatusWork(deliveryWork, routineExecutionStatusService(db as any),
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped);
trackHeartbeatSchedulerWork(status.ready);
}
const scheduled = registerScheduledWork(deliveryWork, {
retryMs: config.heartbeatSchedulerIntervalMs,
tickAgentTimers: async signal => {
@@ -114,6 +114,7 @@ export const DELIVERY_QUEUES = {
pluginLifecycle: "plugin-resource-lifecycle",
toolAction: "tool-action-receipts",
terminalWorkspaces: "terminal-workspaces",
routineExecutionStatus: "routine-execution-status",
routineSchedules: "routine-schedules",
issueMonitors: "issue-monitors",
agentTimers: "agent-timers",
@@ -1,7 +1,9 @@
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
import type { Db } from "@paperclipai/db";
import { DELIVERY_QUEUES, notifyDeliveryWork, type DeliveryQueue } from "./delivery-work-notifications.js";
export async function notifyExecutionRunWork(transaction: object) {
export async function notifyExecutionRunWork(transaction: object, issueId?: string) {
await notifyRoutineExecutionStatus(transaction, issueId);
for (const queue of [DELIVERY_QUEUES.executionStatus, DELIVERY_QUEUES.executionFinalization,
DELIVERY_QUEUES.executionReplacement, DELIVERY_QUEUES.executionDisposition, DELIVERY_QUEUES.issueMonitors]) {
await notifyDeliveryWork(transaction, queue);
@@ -14,6 +16,7 @@ export function writeExecutionControl<T>(
write: (tx: Db) => PromiseLike<T>,
): Promise<T> {
return db.transaction(async transaction => {
await notifyRoutineExecutionStatus(transaction);
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.issueMonitors);
for (const topic of typeof queue === "string" ? [queue] : queue) await notifyDeliveryWork(transaction, topic);
return write(transaction as unknown as Db);
@@ -1044,7 +1044,8 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
if (previous && isHeartbeatRunTerminalStatus(status))
await notifyRetainedNativeSessionCleanup(tx, { companyId: previous.companyId, runId: previous.id });
if (isHeartbeatRunTerminalStatus(status)) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, companyId);
await notifyExecutionRunWork(tx);
await notifyExecutionRunWork(tx, previous?.nativeIssueId ?? previous?.issueId
?? readNonEmptyString(parseObject(previous?.contextSnapshot).issueId) ?? undefined);
if (previous) await notifyChatRunMilestones(tx, { companyId: previous.companyId, runId: previous.id,
issueId: previous.nativeIssueId ?? previous.issueId ?? readNonEmptyString(parseObject(previous.contextSnapshot).issueId) ?? undefined });
return write(tx);
@@ -1,9 +1,11 @@
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
import { notifyNativeReviewResponse } from "./native-review-response-notifications.js";
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
export async function notifyIssueReviewState(tx: object, scope: { companyId: string; issueId?: string; runId?: string; agentId?: string }) {
await notifyRoutineExecutionStatus(tx, scope.issueId);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyExternalConversationSettlement(tx, scope);
await notifyNativeReviewResponse(tx, scope);
+11
View File
@@ -1,3 +1,4 @@
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
import { notifyExternalConversationSettlement } from "./external-conversation-settlement-notifications.js";
import { notifyNativeRecoveryNotices } from "./native-recovery-notice-notifications.js";
import { notifyNativeRunProjection } from "./native-run-projection-notifications.js";
@@ -10606,6 +10607,7 @@ export function issueService(db: Db) {
}
if (values.monitorNextCheckAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
if (values.originKind === "routine_execution") await notifyRoutineExecutionStatus(tx, values.id);
const [issue] = await tx.insert(issues).values(values).returning();
await attachOwnedDraftImages(tx, issue, issue.description, { userId: issueData.createdByUserId, agentId: issueData.createdByAgentId });
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
@@ -11494,6 +11496,7 @@ export function issueService(db: Db) {
if (patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined
|| patch.hiddenAt !== undefined || patch.conversationAgentId !== undefined) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
}
if (patch.monitorNextCheckAt !== undefined || (receiptExisting.monitorNextCheckAt &&
(patch.status !== undefined || patch.assigneeAgentId !== undefined || patch.assigneeUserId !== undefined))) {
@@ -11542,6 +11545,9 @@ export function issueService(db: Db) {
await notifyNativeWorkspaceFinalization(tx, { companyId: receiptExisting.companyId, issueId: id });
await notifyNativeStatusReassessment(tx, { companyId: receiptExisting.companyId, issueId: id });
}
if (patch.originKind !== undefined || patch.originRunId !== undefined || patch.originId !== undefined) {
await notifyRoutineExecutionStatus(tx, id);
}
const updated = await tx
.update(issues)
.set(patch)
@@ -11958,6 +11964,7 @@ export function issueService(db: Db) {
await notifyDecisionTargetIssueRemoval(tx, id);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
removedIssue = await tx
.delete(issues)
.where(eq(issues.id, id))
@@ -12086,6 +12093,7 @@ export function issueService(db: Db) {
const updated = await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
return tx
.update(issues)
.set({
@@ -12215,6 +12223,7 @@ export function issueService(db: Db) {
const adopted = await db.transaction(async tx => {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
return tx
.update(issues)
.set(adoptionSet)
@@ -12470,6 +12479,7 @@ export function issueService(db: Db) {
await notifyNativeStatusReassessment(tx, { companyId: existing.companyId, issueId: id });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
const updated = await tx
.update(issues)
.set({
@@ -12530,6 +12540,7 @@ export function issueService(db: Db) {
}
await notifyDeliveryWork(tx, DELIVERY_QUEUES.issueMonitors);
await notifyDeliveryWork(tx, DELIVERY_QUEUES.agentTimers);
await notifyRoutineExecutionStatus(tx, id);
const updated = await tx
.update(issues)
.set(patch)
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@ -8,13 +9,14 @@ import { routineService } from "../routines.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
const support = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_ROUTINE_STATUS_TEST_DATABASE_URL");
const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping native routine status tests: ${support.reason}`);
(support.supported ? describe : describe.skip)("native routine history status projection", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const companyId = randomUUID(), agentId = randomUUID();
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("native-routine-status-"); db = createDb(database.connectionString);
if (!external) database = await startEmbeddedPostgresTestDatabase("native-routine-status-"); db = createDb(external ?? database.connectionString, { maxConnections: 6 });
await db.insert(companies).values({ id: companyId, name: "Routine status fixture", issuePrefix: "NRS" });
await db.insert(agents).values({ id: agentId, companyId, name: "Routine worker", adapterType: "paperclip_runner", status: "running" });
}, 60_000);
@@ -0,0 +1,5 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export function notifyRoutineExecutionStatus(tx: object, issueId?: string) {
return notifyDeliveryWork(tx, DELIVERY_QUEUES.routineExecutionStatus, issueId ?? "*");
}
@@ -0,0 +1,18 @@
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
import type { routineExecutionStatusService } from "./routine-run-status.js";
export function registerRoutineExecutionStatusWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: ReturnType<typeof routineExecutionStatusService>,
canRun: () => boolean,
) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.routineExecutionStatus,
decodeScope: hint => hint === "*" ? undefined : hint,
matches: (entry, issueId?: string) => issueId === undefined || entry.issueId === issueId,
load: async (issueId?: string) => (await service.load(issueId)).map(row => ({ ...row, dueAt: Date.now() })),
check: async entry => { await service.settle(entry); return null; },
}, canRun);
}
+65 -3
View File
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issues, routineRuns, routines } from "@paperclipai/db";
import { issues, pluginManagedResources, routineRuns, routines } from "@paperclipai/db";
const EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE = "execution_issue_status";
const EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES = ["blocked", "cancelled"] as const;
@@ -37,6 +38,7 @@ function legacyExecutionIssueTransientFailureStatus(
}
export async function finalizeRoutineRun(executor: Db, runId: string, patch: Partial<typeof routineRuns.$inferInsert>) {
if (patch.linkedIssueId) await notifyRoutineExecutionStatus(executor, patch.linkedIssueId);
return executor
.update(routineRuns)
.set({
@@ -67,14 +69,16 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
status: routineRuns.status,
failureReason: routineRuns.failureReason,
triggerPayload: routineRuns.triggerPayload,
completedAt: routineRuns.completedAt,
})
.from(routineRuns)
.innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, issue.companyId)))
.where(and(eq(routineRuns.id, issue.originRunId), eq(routineRuns.companyId, issue.companyId),
eq(routineRuns.linkedIssueId, issue.id)))
.then((rows) => rows[0] ?? null);
if (!run) return null;
if (!run || ["received", "skipped", "coalesced"].includes(run.status)) return null;
if (issue.status === "done") {
if (run.status === "completed" && run.failureReason === null && run.completedAt) return null;
const transientFailureStatus = executionIssueTransientFailureStatusFromPayload(run.triggerPayload)
?? legacyExecutionIssueTransientFailureStatus(run.failureReason);
const transientFailureClearedAt = executionIssueTransientFailureClearedAtFromPayload(run.triggerPayload);
@@ -99,6 +103,7 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
}
if (issue.status === "blocked" || issue.status === "cancelled") {
const failureReason = executionIssueTransientFailureReason(issue.status);
if (run.status === "failed" && run.failureReason === failureReason && run.completedAt) return null;
return finalizeRoutineRun(executor, issue.originRunId, {
status: "failed",
failureReason,
@@ -135,3 +140,60 @@ export async function syncRoutineRunStatusForIssue(executor: Db, issueId: string
return null;
}
export function routineExecutionOriginCondition() {
return or(sql`${issues.originId} = ${routineRuns.routineId}::text`, sql`exists (
select 1 from ${pluginManagedResources} resource
where resource.company_id = ${routineRuns.companyId}
and resource.resource_kind = 'routine' and resource.resource_id = ${routineRuns.routineId}
and resource.defaults_json -> 'issueTemplate' ->> 'originId' = ${issues.originId}
)`);
}
export function routineExecutionStatusDebtCondition() {
const transientFailure = sql`(${routineRuns.triggerPayload} -> 'transientFailure' ->> 'code' = ${EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE}
or ${routineRuns.failureReason} in ('Execution issue moved to blocked', 'Execution issue moved to cancelled'))`;
return or(
and(eq(routineRuns.status, "issue_created"), inArray(issues.status, ["done", "blocked", "cancelled"])),
and(eq(routineRuns.status, "completed"), or(inArray(issues.status, ["blocked", "cancelled"]),
and(eq(issues.status, "done"), sql`(${routineRuns.completedAt} is null or ${routineRuns.failureReason} is not null)`))),
and(eq(routineRuns.status, "failed"), transientFailure,
sql`(${issues.status} not in ('blocked', 'cancelled')
or ${routineRuns.completedAt} is null
or ${routineRuns.failureReason} is distinct from 'Execution issue moved to ' || ${issues.status})`),
);
}
export function routineExecutionStatusService(db: Db) {
async function load(issueId?: string) {
return db.select({ id: routineRuns.id, companyId: routineRuns.companyId,
issueId: issues.id, routineId: routineRuns.routineId })
.from(routineRuns)
.innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, routineRuns.companyId)))
.innerJoin(issues, and(eq(issues.id, routineRuns.linkedIssueId), eq(issues.companyId, routineRuns.companyId),
eq(issues.originKind, "routine_execution"), sql`${issues.originRunId} = ${routineRuns.id}::text`, routineExecutionOriginCondition()))
.where(and(routineExecutionStatusDebtCondition(), ...(issueId ? [eq(issues.id, issueId)] : [])))
.orderBy(routineRuns.id);
}
async function settle(candidate: Awaited<ReturnType<typeof load>>[number]) {
return db.transaction(async tx => {
await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`);
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
const [routine] = await tx.select({ id: routines.id }).from(routines).where(and(
eq(routines.id, candidate.routineId), eq(routines.companyId, candidate.companyId))).for("share");
if (!routine) return false;
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, candidate.issueId),
eq(issues.companyId, candidate.companyId), eq(issues.originKind, "routine_execution"),
eq(issues.originRunId, candidate.id))).for("update");
if (!issue) return false;
const [run] = await tx.select({ id: routineRuns.id }).from(routineRuns)
.innerJoin(issues, eq(issues.id, routineRuns.linkedIssueId))
.where(and(eq(routineRuns.id, candidate.id), eq(routineRuns.companyId, candidate.companyId),
eq(routineRuns.routineId, candidate.routineId), eq(routineRuns.linkedIssueId, issue.id),
routineExecutionOriginCondition(), routineExecutionStatusDebtCondition())).for("update", { of: routineRuns });
if (!run) return false;
return (await syncRoutineRunStatusForIssue(tx as unknown as Db, issue.id)) !== null;
});
}
return { load, settle };
}
+6 -35
View File
@@ -73,7 +73,7 @@ import { secretService } from "./secrets.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
import { parseCron, validateCron } from "./cron.js";
import { heartbeatService } from "./heartbeat.js";
import { finalizeRoutineRun, syncRoutineRunStatusForIssue } from "./routine-run-status.js";
import { finalizeRoutineRun, routineExecutionStatusService, syncRoutineRunStatusForIssue } from "./routine-run-status.js";
import {
instanceSettingsService,
isTruthyRuntimeEnvValue,
@@ -2001,7 +2001,6 @@ export function routineService(
return run;
}
let statusReconcileCursor: string | null = null;
async function syncRunStatusForIssue(issueId: string, executor: Db = db) {
return syncRoutineRunStatusForIssue(executor, issueId);
@@ -3245,41 +3244,13 @@ export function routineService(
return { triggered };
},
// Repair a missed issue-status hook through the same status transition as
// normal issue writes. Never discard queued input or infer run completion
// from age. Lock both owners and re-read the issue before applying repair.
reconcileExecutionRunStatuses: async (): Promise<{ scanned: number; settled: number; deferred: number }> => {
const candidates = await db.select({ id: routineRuns.id, companyId: routineRuns.companyId,
issueId: issues.id, routineId: routineRuns.routineId })
.from(routineRuns).innerJoin(routines, and(eq(routines.id, routineRuns.routineId), eq(routines.companyId, routineRuns.companyId)))
.innerJoin(issues, and(eq(issues.id, routineRuns.linkedIssueId),
eq(issues.companyId, routineRuns.companyId), eq(issues.originKind, "routine_execution"),
sql`${issues.originRunId} = ${routineRuns.id}::text`, sql`${issues.originId} = ${routineRuns.routineId}::text`))
.where(and(eq(routineRuns.status, "issue_created"), inArray(issues.status, ["done", "cancelled", "blocked"]),
...(statusReconcileCursor ? [sql`${routineRuns.id}::text > ${statusReconcileCursor}`] : [])))
.orderBy(asc(routineRuns.id)).limit(50);
statusReconcileCursor = candidates.length === 50 ? candidates.at(-1)!.id : null;
let settled = 0;
let deferred = 0;
const service = routineExecutionStatusService(db);
const candidates = await service.load();
let settled = 0, deferred = 0;
for (const candidate of candidates) {
try {
const repaired = await db.transaction(async tx => {
await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`);
await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
const [routine] = await tx.select({ id: routines.id }).from(routines).where(and(
eq(routines.id, candidate.routineId), eq(routines.companyId, candidate.companyId))).for("share");
if (!routine) return false;
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, candidate.issueId),
eq(issues.companyId, candidate.companyId), eq(issues.originKind, "routine_execution"),
eq(issues.originRunId, candidate.id), eq(issues.originId, candidate.routineId))).for("update");
if (!issue || !["done", "cancelled", "blocked"].includes(issue.status)) return false;
const [run] = await tx.select().from(routineRuns).where(and(eq(routineRuns.id, candidate.id),
eq(routineRuns.companyId, candidate.companyId), eq(routineRuns.routineId, candidate.routineId),
eq(routineRuns.linkedIssueId, issue.id), eq(routineRuns.status, "issue_created"))).for("update");
if (!run) return false;
return (await syncRunStatusForIssue(issue.id, tx as unknown as Db)) !== null;
});
if (repaired) settled += 1;
if (await service.settle(candidate)) settled++;
} catch (error) {
let current: unknown = error;
let retryable = false;
@@ -3289,7 +3260,7 @@ export function routineService(
current = cause.cause;
}
if (!retryable) throw error;
deferred += 1;
deferred++;
}
}
return { scanned: candidates.length, settled, deferred };