Merge current master execution recovery into idle ingress

This commit is contained in:
Dotta committed 2026-11-03 13:25:06 -06:00
commit 2ea1fe6be1
9 files changed
+350 -17

No files matched your search

@@ -64519,6 +64519,45 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
} finally { await f.service.shutdown(); }
});
it("retries saved GitHub input after an endpoint admission lock collision", async () => {
const f = await successfulGitHubQueuedInputFixture();
const heartbeat = heartbeatService(db);
const failures: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
onError: error => failures.push(error) });
let unlock!: () => void, locked!: () => void;
const acquired = new Promise<void>(resolve => { locked = resolve; });
const released = new Promise<void>(resolve => { unlock = resolve; });
const competingAdmission = db.transaction(async tx => {
await tx.select({ id: chatEndpoints.id }).from(chatEndpoints)
.where(eq(chatEndpoints.id, f.endpoint.id)).for("no key update");
locked();
await released;
});
try {
await acquired;
await heartbeat.registerDeferredReceiptWork(coordinator, () => true).ready;
const priorFailures = failures.length;
const retry = await requestTaskRetry(f.input);
await expect.poll(() => failures.length, { timeout: 5_000 }).toBeGreaterThan(priorFailures);
expect((await readAgentWakeupRequestsById(f.pending!.id))[0].runId).toBeNull();
unlock(); await competingAdmission;
await expect.poll(async () => (await readAgentWakeupRequestsById(f.pending!.id))[0].runId,
{ timeout: 40_000 }).toBeTruthy();
await expect.poll(async () => (await readAgentWakeupRequestsById(retry.requestId!))[0].status,
{ timeout: 40_000 }).toBe("coalesced");
const [saved] = await readAgentWakeupRequestsById(f.pending!.id);
expect((await readHeartbeatRunsByCompanyId(f.fixture.companyId))
.filter(run => run.wakeupRequestId === f.pending!.id)).toHaveLength(1);
expect((await readAgentWakeupRequestsById(retry.requestId!))[0].runId).toBe(saved.runId);
expect((await readHeartbeatRunsById(f.runId))[0]).toMatchObject({ status: "succeeded" });
} finally {
unlock(); await competingAdmission;
await coordinator.stop();
await f.service.shutdown();
}
}, 90_000);
it.each(["reconnected", "newer_input", "reconnected_newer_input", "snapshot_newer_input"] as const)(
"admits current GitHub task Retry without replaying obsolete input: %s", async kind => {
const f = await currentGitHubTaskRetryFixture(kind.includes("newer_input"));
@@ -72,14 +72,18 @@ describe("deferred receipt durable dispatch", () => {
// during actual process execution; accelerated steps only settle idle work.
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
heartbeat = heartbeatService(db);
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !drain, canReconcile: () => true,
coordinator = createCoordinator();
});
function createCoordinator() {
const worker = createDeliveryWorkCoordinator({ owner: db, canRun: () => !drain, canReconcile: () => true,
onError: error => { errors.push(error); errorTimes.push(Date.now()); } });
const register = coordinator.register.bind(coordinator);
coordinator.register = (queue, task) => register(queue, { ...task, run: signal => {
const register = worker.register.bind(worker);
worker.register = (queue, task) => register(queue, { ...task, run: signal => {
const pass = Promise.resolve(task.run(signal)); passes.add(pass);
void pass.then(() => passes.delete(pass), () => passes.delete(pass)); return pass;
} });
});
return worker;
}
afterEach(async () => {
await coordinator.stop(); await heartbeat.drainActiveRunExecutions();
for (const stop of extraStops.splice(0)) await stop();
@@ -153,6 +157,78 @@ describe("deferred receipt durable dispatch", () => {
source: "on_demand", status: "completed", reason: "task_retry", payload: { issueId: f.issue.id } });
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready; await quiet();
});
it.each([false, true])("reconsiders every saved receipt during repeated company eligibility changes (expired wait: %s)", async expiredWait => {
const f = await fixture();
const ids = Array.from({ length: 178 }, (_, index) => `${(index + 1).toString(16).padStart(8, "0")}-0000-4000-8000-000000000000`);
await db.insert(agentWakeupRequests).values(ids.map(id => ({ id, companyId: f.companyId, agentId: f.agent.id,
source: "on_demand", status: "deferred_issue_execution",
updatedAt: new Date(Date.now() - 60_000),
payload: { issueId: f.issue.id, _paperclipWakeContext: { wakeCommentIds: [randomUUID()] },
...(expiredWait ? { executionWait: { reason: "recovery" } } : {}) },
})));
const checked = new Set<string>();
await registerDeferredReceiptWork(db, coordinator, {
resume: async scope => { checked.add(scope.requestId!); },
settleTaskChat: async () => {}, createNativeBatch: () => async () => {}, schedulingSuppressed: async () => false,
}, () => !drain).ready;
for (let pass = 0; pass < 12; pass++) {
await db.transaction(tx => notifyDeferredExecutionEligibility(tx, { companyId: f.companyId }));
await vi.advanceTimersByTimeAsync(31_000);
await Promise.allSettled([...passes]);
await immediate();
}
expect(checked.size).toBe(ids.length);
await quiet();
});
it("dispatches task Retry past a repeatedly invalidated backlog into real process output", async () => {
const f = await fixture();
// Hold executor capacity while queue time advances; reconstruct the saved
// run with fresh workers at PostgreSQL time before real process execution.
const [occupant] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agent.id,
status: "running", startedAt: new Date() }).returning();
await db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: true, intervalSec: 0,
wakeOnDemand: true, maxConcurrentRuns: 1 } } }).where(eq(agents.id, f.agent.id));
const [closed] = await db.insert(issues).values({ companyId: f.companyId, title: "Closed task history",
status: "done", assigneeAgentId: f.agent.id }).returning();
await db.insert(agentWakeupRequests).values(Array.from({ length: 178 }, (_, index) => ({
id: `${(index + 1).toString(16).padStart(8, "0")}-0000-4000-8000-000000000000`,
companyId: f.companyId, agentId: f.agent.id, source: "on_demand", status: "deferred_issue_execution",
payload: { issueId: closed.id, _paperclipWakeContext: { wakeCommentIds: [randomUUID()] } },
})));
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready;
await heartbeat.registerScheduledRetryCancellationWork(coordinator, () => !drain).ready;
const retry = await requestTaskRetry({ db, companyId: f.companyId, issueId: f.issue.id, actorId: f.actorId,
observedRunId: f.source.id, idempotencyKey: randomUUID() });
const read = async () => (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, retry.requestId!)))[0];
for (let pass = 0; pass < 12 && !(await read()).runId; pass++) {
await db.transaction(tx => notifyDeferredExecutionEligibility(tx, { companyId: f.companyId }));
await vi.advanceTimersByTimeAsync(31_000);
await Promise.allSettled([...passes]);
await immediate();
}
const receipt = await read();
expect(receipt.runId).toBeTruthy();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!)))[0].status).toBe("queued");
await coordinator.stop();
await heartbeat.drainActiveRunExecutions();
vi.setSystemTime(vi.getRealSystemTime());
heartbeat = heartbeatService(db);
coordinator = createCoordinator();
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready;
await heartbeat.registerScheduledRetryCancellationWork(coordinator, () => !drain).ready;
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, occupant.id));
await heartbeat.registerRunDispatchWork(coordinator, () => !drain).ready;
await waitForReal(async () => {
await heartbeat.drainActiveRunExecutions();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!));
expect(run.status, JSON.stringify({ error: run.error, code: run.errorCode })).toBe("succeeded");
expect(run.stdoutExcerpt).toContain("receipt-proof");
}, { timeout: 15_000 });
expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(sql`${heartbeatRuns.contextSnapshot}->>'taskRetryRequestId' = ${retry.requestId}`)).toHaveLength(1);
await finish(f.issue.id);
await quiet();
});
it("dispatches an ordinary committed task Retry into saved process output", async () => {
const f = await fixture();
await heartbeat.registerDeferredReceiptWork(coordinator, () => !drain).ready;
@@ -1,3 +1,4 @@
import * as wakeSettlementNotifications from "../services/external-conversation-settlement-notifications.js";
import { heartbeatService } from "../services/heartbeat.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { createHeartbeatLifecycle, type HeartbeatLifecycleDependencies } from "../services/heartbeat/run-lifecycle.js";
@@ -8,14 +9,14 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { agents, companies, completionContracts, createDb, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, companies, completionContracts, createDb, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, type Db } from "@paperclipai/db";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { nativeRunProjectionWork } from "../services/native-runtime/native-run-projection-work.js";
import { projectCommittedRun } from "../services/native-runtime/native-run-projection.js";
import { notifyNativeFinalizationWork } from "../services/native-finalization-notifications.js";
import { finalizeNativeRun } from "../services/native-runtime/native-run-finalizer.js";
import { finalizeNativeRun, recordNativeFinalizationFailure } from "../services/native-runtime/native-run-finalizer.js";
import * as statusCommitter from "../services/native-runtime/status-decision-committer.js";
import { PaperclipControlPlanePort } from "../services/native-runtime/paperclip-control-plane-port.js";
import { NativeRunCoordinatorStore } from "../services/native-runtime/native-run-coordinator-store.js";
@@ -120,6 +121,154 @@ describe("committed native run projection lifecycle", () => {
async function accepted(status: "succeeded" | "failed" | "cancelled" = "succeeded") {
const binding = await seed(); await complete(binding, "control_plane", db, status); await barrier(binding); return binding;
}
async function claimedWake(binding: Awaited<ReturnType<typeof seed>>) {
const [wake] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "issue",
status: "claimed", runId: binding.runId, claimedAt: new Date(), payload: { issueId: binding.issueId } }).returning();
await db.update(heartbeatRuns).set({ wakeupRequestId: wake.id }).where(eq(heartbeatRuns.id, binding.runId));
return wake;
}
const readWake = async (id: string) => (await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, id)))[0];
it.each(["succeeded", "failed", "cancelled"] as const)("settles the exact wake atomically with a fresh %s projection", async status => {
const binding = await accepted(status), wake = await claimedWake(binding);
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
const run = await read(binding.runId), settled = await readWake(wake.id);
expect(run.status).toBe(status);
expect(settled).toMatchObject({ status: status === "succeeded" ? "completed" : status, runId: binding.runId,
companyId, agentId, finishedAt: run.finishedAt, error: run.error });
expect(settled.finishedAt).not.toBeNull();
await projectCommittedRun({ db, run, coordinator: await state(binding.runId) });
expect(await readWake(wake.id)).toEqual(settled);
});
it.each(["provider", "workspace"] as const)("settles the exact wake when %s finalization retries exhaust", async scope => {
const binding = await accepted(), wake = await claimedWake(binding);
if (scope === "workspace") await db.update(workspaceOperations).set({ status: "failed" })
.where(eq(workspaceOperations.heartbeatRunId, binding.runId));
if (scope === "provider") await db.update(nativeRunResults).set({
resultJson: sql`jsonb_set(${nativeRunResults.resultJson}, '{terminal,runTerminalState}', '"unknown"'::jsonb)`,
}).where(eq(nativeRunResults.runId, binding.runId));
for (let attempt = 0; attempt < 3; attempt++) {
await db.update(nativeRunFinalizations).set({ nextAttemptAt: new Date(0) })
.where(eq(nativeRunFinalizations.runId, binding.runId));
if (scope === "provider") await reconcileNativeFinalizations(db, [binding.runId]);
else await recordNativeFinalizationFailure({ db, runId: binding.runId, projectRunStatus: true,
failureScope: "workspace", error: new Error("native_workspace_finalize_failed") });
if (attempt < 2) {
expect((await read(binding.runId)).status).toBe("running");
expect(await readWake(wake.id)).toMatchObject({ status: "claimed", finishedAt: null });
}
}
const run = await read(binding.runId);
expect((await state(binding.runId)).phase).toBe("terminal_failure");
expect(run).toMatchObject({ status: scope === "provider" ? "succeeded" : "failed", nativePhase: "terminal_failure" });
expect(run.finishedAt).not.toBeNull();
expect(await readWake(wake.id)).toMatchObject({ status: scope === "provider" ? "completed" : "failed",
runId: binding.runId, finishedAt: run.finishedAt });
});
it("keeps the source wake claimed when exhausted failure does not own terminal projection", async () => {
const binding = await accepted(), wake = await claimedWake(binding);
await db.update(workspaceOperations).set({ status: "failed" }).where(eq(workspaceOperations.heartbeatRunId, binding.runId));
for (let attempt = 0; attempt < 3; attempt++) await recordNativeFinalizationFailure({ db, runId: binding.runId,
projectRunStatus: false, failureScope: "workspace", error: new Error("native_workspace_finalize_failed") });
expect((await state(binding.runId)).phase).toBe("terminal_failure");
expect((await read(binding.runId)).status).toBe("running");
expect(await readWake(wake.id)).toEqual(wake);
});
it("rolls back exhausted projection and its claim if wake notification registration fails", async () => {
const binding = await accepted(), wake = await claimedWake(binding);
await db.update(workspaceOperations).set({ status: "failed" }).where(eq(workspaceOperations.heartbeatRunId, binding.runId));
const fail = () => recordNativeFinalizationFailure({ db, runId: binding.runId,
projectRunStatus: true, failureScope: "workspace" as const, error: new Error("native_workspace_finalize_failed") });
await fail(); await fail();
const beforeRun = await read(binding.runId), beforeCoordinator = await state(binding.runId);
const notify = wakeSettlementNotifications.notifyExternalConversationSettlement;
vi.spyOn(wakeSettlementNotifications, "notifyExternalConversationSettlement").mockImplementation(async (tx, scope) => {
const [current] = await tx.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
expect(current.status).toBe("claimed");
await notify(tx, scope);
throw new Error("exhaustion wake notification failure");
});
await expect(fail()).rejects.toThrow("exhaustion wake notification failure");
expect(await read(binding.runId)).toEqual(beforeRun);
expect(await state(binding.runId)).toEqual(beforeCoordinator);
expect(await readWake(wake.id)).toEqual(wake);
});
it.each(["succeeded", "failed", "cancelled"] as const)("discovers and repairs an old %s run's claimed wake once", async status => {
const binding = await accepted(status);
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
const run = await read(binding.runId), wake = await claimedWake(binding);
const work = nativeRunProjectionWork(db);
const [entry] = await work.load({ companyId, runId: binding.runId });
expect(entry).toMatchObject({ id: binding.runId });
await work.check(entry);
expect(await readWake(wake.id)).toMatchObject({ status: status === "succeeded" ? "completed" : status,
finishedAt: run.finishedAt });
expect((await read(binding.runId)).finishedAt).toEqual(run.finishedAt);
expect(await work.load({ companyId, runId: binding.runId })).toEqual([]);
});
it.each(["unbound", "newer-run", "queued", "cancelled", "foreign-company", "foreign-agent", "live-owner"] as const)(
"does not settle a %s wake claim", async fault => {
const binding = await accepted(), wake = await claimedWake(binding);
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded" });
if (fault === "unbound" || fault === "newer-run") await db.update(agentWakeupRequests)
.set({ runId: fault === "unbound" ? null : randomUUID() }).where(eq(agentWakeupRequests.id, wake.id));
if (fault === "queued" || fault === "cancelled") await db.update(agentWakeupRequests)
.set({ status: fault }).where(eq(agentWakeupRequests.id, wake.id));
if (fault === "foreign-company") {
const otherCompany = randomUUID();
await db.insert(companies).values({ id: otherCompany, name: "Other wake owner", issuePrefix: `OW${randomUUID().slice(0, 6)}` });
await db.update(agentWakeupRequests).set({ companyId: otherCompany }).where(eq(agentWakeupRequests.id, wake.id));
}
if (fault === "foreign-agent") {
const otherAgent = randomUUID();
await db.insert(agents).values({ id: otherAgent, companyId, name: "Other wake owner", adapterType: "paperclip_runner" });
await db.update(agentWakeupRequests).set({ agentId: otherAgent }).where(eq(agentWakeupRequests.id, wake.id));
}
if (fault === "live-owner") await db.update(heartbeatRuns).set({ status: "running", nativePhase: "terminal_failure",
errorCode: "native_execution_ownership_unverified" }).where(eq(heartbeatRuns.id, binding.runId));
const before = await readWake(wake.id);
await projectCommittedRun({ db, run: await read(binding.runId), coordinator: await state(binding.runId) });
expect(await readWake(wake.id)).toEqual(before);
if (fault === "live-owner") expect((await read(binding.runId)).status).toBe("running");
},
);
it("registers wake settlement intent before mutation and rolls back notification failure", async () => {
const binding = await accepted(), wake = await claimedWake(binding);
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded" });
const before = await read(binding.runId);
const notify = wakeSettlementNotifications.notifyExternalConversationSettlement;
const notification = vi.spyOn(wakeSettlementNotifications, "notifyExternalConversationSettlement")
.mockImplementation(async (tx, scope) => {
const [current] = await tx.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
expect(current.status).toBe("claimed");
await notify(tx, scope);
throw new Error("wake intent registration failure");
});
await expect(projectCommittedRun({ db, run: before, coordinator: await state(binding.runId) }))
.rejects.toThrow("wake intent registration failure");
expect(notification).toHaveBeenCalledOnce();
expect(await read(binding.runId)).toEqual(before);
expect(await readWake(wake.id)).toEqual(wake);
});
it("rolls back terminal projection and exact wake settlement together", async () => {
const binding = await accepted(), wake = await claimedWake(binding);
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded" });
const before = await read(binding.runId);
await expect(db.transaction(async tx => {
await projectCommittedRun({ db: tx as unknown as Db, run: before, coordinator: await state(binding.runId) });
throw new Error("projection rollback fixture");
})).rejects.toThrow("projection rollback fixture");
expect(await read(binding.runId)).toEqual(before);
expect(await readWake(wake.id)).toEqual(wake);
});
it("reconstructs a committed decision with an unfinished run projection and stays quiet after repair", async () => {
const binding = await accepted();
await finalizeNativeRun({ db, runId: binding.runId, workspaceFinalizeStatus: "succeeded" });
@@ -6,7 +6,7 @@ import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import type { DeferredExecutionScope, DeferredReceiptRecoveryOptions } from "../deferred-execution-notifications.js";
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
type Entry = { id: string; companyId: string; dueAt: number };
type Entry = { id: string; companyId: string; dueAt: number; immediate: boolean };
type NativeIntentOptions = DeferredExecutionScope & {
onDeferredDeadline(at: number): void;
onError(error: unknown): void;
@@ -39,6 +39,11 @@ export function registerDeferredReceiptWork(
},
decodeScope: hint => hint === "*" ? undefined : JSON.parse(hint),
matches: (entry, scope) => !scope || entry.companyId === scope.companyId && (!scope.requestId || entry.id === scope.requestId),
// A busy company can invalidate a paginated scan before the pending
// batch drains. Keep unread ready receipts ahead of freshly reloaded
// receipts already checked; actual wait/claim deadlines still replace.
mergeEntry: (pending, loaded) => pending.immediate && loaded.immediate
? { ...loaded, dueAt: Math.min(pending.dueAt, loaded.dueAt) } : loaded,
load: async scope => {
const key = scope ? JSON.stringify({ companyId: scope.companyId, requestId: scope.requestId }) : "*";
if (!enabled) { cursors.delete(key); return []; }
@@ -76,12 +81,15 @@ export function registerDeferredReceiptWork(
)).orderBy(asc(agentWakeupRequests.id)).limit(100);
const complete = rows.length < 100;
if (complete) cursors.delete(key); else cursors.set(key, rows.at(-1)!.id);
return { rows: rows.map(row => ({ id: row.id, companyId: row.companyId,
dueAt: kind === "native" && row.status === "claimed" && row.claimedAt ? row.claimedAt.getTime() + 60_000
const now = Date.now();
return { rows: rows.map(row => {
const deadline = kind === "native" && row.status === "claimed" && row.claimedAt ? row.claimedAt.getTime() + 60_000
: kind === "native" && row.error ? row.updatedAt.getTime() + 30_000
: kind === "deferred" && row.delayed && !row.interruptedLegacy ? row.updatedAt.getTime() + 30_000
: Date.now(),
})), complete };
: null;
const immediate = deadline === null || deadline <= now;
return { id: row.id, companyId: row.companyId, dueAt: immediate ? now : deadline, immediate };
}), complete };
},
check: async entry => {
if (!enabled) return null;
@@ -101,7 +109,7 @@ export function registerDeferredReceiptWork(
: kind === "deferred" && current.status === "deferred_issue_execution" && !current.interruptedLegacy
&& (current.payload?.queuedCommentInterrupt || current.payload?.executionWait)
? current.updatedAt.getTime() + 30_000 : null;
if (due !== null && due > Date.now()) return { ...entry, dueAt: due };
if (due !== null && due > Date.now()) return { ...entry, dueAt: due, immediate: false };
}
const errors: unknown[] = [];
let next: number | null = null;
@@ -117,7 +125,7 @@ export function registerDeferredReceiptWork(
catch (error) { failed(error); }
}
if (errors.length) throw new AggregateError(errors, `Deferred receipt ${entry.id} could not settle`);
return next === null ? null : { ...entry, dueAt: next };
return next === null ? null : { ...entry, dueAt: next, immediate: false };
},
}, canRun);
}
@@ -65,6 +65,26 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () =>
const input = { companyId, issueId, runId };
return { ...input, input, agentId, userId, wakeId, prpEvent, reply };
}
it("settles the exact claimed wake when recovery projects a committed native run", async () => {
// The provider and result are already committed. Recovery owns terminal
// projection because the live executor will not return to completeRun.
const f = await fixture(false, { succeed: false });
const sourceWakeId = randomUUID();
await db.insert(agentWakeupRequests).values({ id: sourceWakeId, companyId: f.companyId,
agentId: f.agentId, source: "issue", reason: "issue_assigned", status: "claimed",
runId: f.runId, claimedAt: new Date(), payload: { issueId: f.issueId } });
await db.update(heartbeatRuns).set({ wakeupRequestId: sourceWakeId }).where(eq(heartbeatRuns.id, f.runId));
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, sourceWakeId));
expect(run).toMatchObject({ status: "succeeded", nativePhase: "committed" });
expect(run.finishedAt).not.toBeNull();
expect(wake).toMatchObject({ status: "completed", runId: f.runId });
expect(wake.finishedAt).not.toBeNull();
});
const comments = (runId: string) => db.select().from(issueComments).where(eq(issueComments.createdByRunId, runId));
const issue = async (issueId: string) => (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
@@ -3,7 +3,7 @@ import { notifyExternalAgentSettlement } from "../../modules/external-agents/ind
import { notifyNativeRecoveryNotices } from "../native-recovery-notice-notifications.js";
import { resolveCommittedNativeRecoveryNotices } from "./native-recovery-notices.js";
import { notifyNativeRunProjection } from "../native-run-projection-notifications.js";
import { projectCommittedRun, recoveredExecutionFailureMetadata } from "./native-run-projection.js";
import { projectCommittedRun, recoveredExecutionFailureMetadata, settleNativeTerminalWake } from "./native-run-projection.js";
import { notifyNativeResultCommit } from "../native-result-commit-notifications.js";
import { notifyRetainedNativeSessionCleanup } from "../retained-native-session-cleanup-notifications.js";
import { notifyNativeFinalizationWork } from "../native-finalization-notifications.js";
@@ -465,6 +465,7 @@ async function recordRetryableFailure(input: {
})
.where(eq(heartbeatRuns.id, input.run.id))
.returning();
if (projectsTerminalStatus && updatedRun) await settleNativeTerminalWake(tx as unknown as Db, updatedRun);
if (projectsTerminalStatus) terminalRunToEmit = updatedRun ?? null;
if (supersededByNewerRun) {
await issueRecoveryActionService(
@@ -1679,6 +1680,7 @@ export async function finalizeNativeRun(input: {
await notifyExecutionRunWork(tx, { companyId: run.companyId });
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId: coordinator.issueId });
const [after] = await projectRun(tx);
if (after && input.projectRunStatus) await settleNativeTerminalWake(tx, after);
return after ? { before, after } : null;
});
const updatedRun = projection?.after;
@@ -1,3 +1,5 @@
import { notifyExternalConversationSettlement } from "../external-conversation-settlement-notifications.js";
import { notifyStrandedAgentStatus } from "../live-run-recovery-notifications.js";
import { notifyNativeEnvironmentSettlement } from "../native-environment-settlement-notifications.js";
import { notifyExternalAgentSettlement } from "../../modules/external-agents/index.js";
import { notifyNativeRecoveryNotices } from "../native-recovery-notice-notifications.js";
@@ -5,7 +7,7 @@ import { pendingNativeStatusEffectsCondition } from "./native-pending-effect-wor
import { notifyNativeRunProjection } from "../native-run-projection-notifications.js";
import { randomUUID } from "node:crypto";
import { and, eq, exists, inArray, isNotNull, isNull, not, or, sql, type SQL } from "drizzle-orm";
import { heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { agentWakeupRequests, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { notifyRetainedNativeSessionCleanup } from "../retained-native-session-cleanup-notifications.js";
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyCancelledWorkspacePreservation } from "../cancelled-workspace-preservation-notifications.js";
@@ -26,11 +28,41 @@ export function projectNativeTerminalRunStatus(
function claimedNativeWakeNeedsSettlement() {
return sql`exists (select 1 from ${agentWakeupRequests} where
${agentWakeupRequests.id} = ${heartbeatRuns.wakeupRequestId}
and ${agentWakeupRequests.companyId} = ${heartbeatRuns.companyId}
and ${agentWakeupRequests.agentId} = ${heartbeatRuns.agentId}
and ${agentWakeupRequests.runId} = ${heartbeatRuns.id}
and ${agentWakeupRequests.status} = 'claimed')`;
}
/** Settle only the exact persisted source claim inside terminal projection. */
export async function settleNativeTerminalWake(tx: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
"id" | "companyId" | "agentId" | "wakeupRequestId" | "runtimeMode" | "status" | "finishedAt" | "error">) {
if (run.runtimeMode !== "native" || !run.wakeupRequestId || !run.finishedAt
|| !["succeeded", "failed", "cancelled"].includes(run.status)) return;
const claim = and(eq(agentWakeupRequests.id, run.wakeupRequestId),
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
eq(agentWakeupRequests.runId, run.id), eq(agentWakeupRequests.status, "claimed"));
const [current] = await tx.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.where(claim).for("update").limit(1);
if (!current) return;
await notifyExternalConversationSettlement(tx, { companyId: run.companyId });
await notifyStrandedAgentStatus(tx, { companyId: run.companyId, agentId: run.agentId });
await tx.update(agentWakeupRequests).set({
status: run.status === "succeeded" ? "completed" : run.status,
finishedAt: run.finishedAt, error: run.error, updatedAt: new Date(),
}).where(claim);
}
export function nativeRunProjectionNeedsRepair(projectedStatus: "succeeded" | "failed" | "cancelled" | SQL) {
return and(
eq(heartbeatRuns.runtimeMode, "native"),
or(
inArray(heartbeatRuns.status, ["queued", "running", "failed"]),
and(eq(heartbeatRuns.status, projectedStatus),
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled"]), claimedNativeWakeNeedsSettlement()),
and(
eq(heartbeatRuns.status, "succeeded"),
or(
@@ -51,6 +83,7 @@ export function nativeRunProjectionNeedsRepair(projectedStatus: "succeeded" | "f
or(
sql`${heartbeatRuns.status} is distinct from ${projectedStatus}`,
claimedNativeWakeNeedsSettlement(),
isNull(heartbeatRuns.finishedAt),
sql`${heartbeatRuns.nativePhase} is distinct from 'committed'`,
sql`${heartbeatRuns.resultJson}->>'finalizationPhase' is distinct from 'committed'`,
@@ -164,6 +197,7 @@ export async function projectCommittedRun(input: {
})
.where(needsRepair)
.returning();
if (after) await settleNativeTerminalWake(tx, after);
return after ? { before, after } : null;
});
+4 -1
View File
@@ -18,6 +18,8 @@ export function registerScopedDeadlineWork<Entry extends ScheduledEntry, Scope>(
decodeScope(hint: string): Scope | undefined;
matches(entry: Entry, scope?: Scope): boolean;
load(scope?: Scope, signal?: AbortSignal): Promise<Entry[] | { rows: Entry[] | null; complete: boolean }>;
// Owners may retain pending queue priority while refreshing eligibility.
mergeEntry?(pending: Entry, loaded: Entry): Entry;
check(entry: Entry, signal?: AbortSignal): Promise<Entry | null>;
},
canRun: () => boolean,
@@ -90,7 +92,8 @@ export function registerScopedDeadlineWork<Entry extends ScheduledEntry, Scope>(
const loaded = await options.load(scope, signal);
const result = Array.isArray(loaded) ? { rows: loaded, complete: true } : loaded;
for (const entry of result.rows ?? []) {
entries.set(entry.id, entry);
const pending = entries.get(entry.id);
entries.set(entry.id, pending && options.mergeEntry ? options.mergeEntry(pending, entry) : entry);
for (const [other, progress] of scans) if (options.matches(entry, options.decodeScope(other))) progress.seen.add(entry.id);
}
if (!result.complete) { dirty.add(hint); continue; }