mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
fix(server): settle routine status from committed work
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user