mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
Reviewed and verified at f1c48c924d.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
325 lines
19 KiB
TypeScript
325 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
|
import { HttpError } from "../../errors.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { createRunDispatch, type PostCommitEffect } from "../../modules/run-dispatch/index.js";
|
|
import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js";
|
|
import * as legacy from "../heartbeat.js";
|
|
import { createHeartbeatRunState } from "./run-state.js";
|
|
import { pendingCaseHandoffForRun } from "./case-handoff-lifecycle.js";
|
|
import * as extracted from "./retries.js";
|
|
import type { HeartbeatRetryDependencies } from "./retries.js";
|
|
|
|
type Run = typeof heartbeatRuns.$inferSelect;
|
|
type Agent = typeof agents.$inferSelect;
|
|
|
|
function callbacks() {
|
|
return {
|
|
hasCaseHandoffForRun: vi.fn<HeartbeatRetryDependencies["hasCaseHandoffForRun"]>(async () => false),
|
|
getRun: vi.fn<HeartbeatRetryDependencies["getRun"]>(async () => null),
|
|
getAgent: vi.fn<HeartbeatRetryDependencies["getAgent"]>(async () => undefined),
|
|
resolveSessionBeforeForWakeup: vi.fn(async () => null),
|
|
resolveResponsibleUserIdForRunContext: vi.fn(async () => "retry-user"),
|
|
getAgentInvokability: vi.fn<HeartbeatRetryDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
|
|
appendRunEvent: vi.fn<HeartbeatRetryDependencies["appendRunEvent"]>(async () => undefined),
|
|
escalatePlanApprovalResumeFailureNeedsAttention: vi.fn(async () => null),
|
|
recordPlanApprovalResumeFailureRetry: vi.fn(async () => null),
|
|
setRunStatusIfRunning: vi.fn<HeartbeatRetryDependencies["setRunStatusIfRunning"]>(async () => ({ updated: false, run: null })),
|
|
setWakeupStatus: vi.fn(async () => undefined),
|
|
releaseIssueExecutionAndPromote: vi.fn(async () => undefined),
|
|
finalizeAgentStatus: vi.fn(async () => undefined),
|
|
getWorktreeExecutionCutoff: vi.fn(async (): Promise<Date | null> => null),
|
|
applyRunDispatchPostCommitEffects: vi.fn<(effects: PostCommitEffect[]) => void>(),
|
|
runDispatch: {
|
|
nextScheduledRetryAt: vi.fn(async () => null),
|
|
evaluateScheduledRetryGate: vi.fn<HeartbeatRetryDependencies["runDispatch"]["evaluateScheduledRetryGate"]>(async () => ({ allowed: true })),
|
|
promoteDueScheduledRetries: vi.fn<HeartbeatRetryDependencies["runDispatch"]["promoteDueScheduledRetries"]>(async () => ({ promoted: 0, runIds: [], postCommitEffects: [] })),
|
|
promoteScheduledRetry: vi.fn<HeartbeatRetryDependencies["runDispatch"]["promoteScheduledRetry"]>(async () => ({ outcome: "not_promoted" })),
|
|
},
|
|
} satisfies HeartbeatRetryDependencies;
|
|
}
|
|
|
|
// These tests must never query: only the injected lifecycle callbacks may run.
|
|
function guardedDatabase() {
|
|
const access = vi.fn(() => { throw new Error("Unexpected retry database access"); });
|
|
return { db: new Proxy({}, { get: access }) as Db, access };
|
|
}
|
|
|
|
function unitRun(): Run {
|
|
const run: Partial<Run> = {
|
|
id: "run", companyId: "company", agentId: "agent", status: "running",
|
|
scopeKind: "issue", issueId: "issue",
|
|
wakeupRequestId: "wake", scheduledRetryAttempt: 0,
|
|
contextSnapshot: { issueId: "issue", timerClaimWasFirstHeartbeat: true },
|
|
};
|
|
return run as Run;
|
|
}
|
|
|
|
function deferral() {
|
|
return new extracted.WorkspaceBusyDeferral({
|
|
holder: { runId: "holder", agentId: "other-agent", issueId: "other-issue", issueIdentifier: "T-2" },
|
|
projectWorkspaceId: "workspace", deferralAttempt: 0, wasIssueAssignee: true,
|
|
});
|
|
}
|
|
|
|
describe("heartbeat retry module callbacks", () => {
|
|
it("preserves legacy helper and error-class identity", () => {
|
|
expect(legacy.computeBoundedTransientHeartbeatRetrySchedule).toBe(extracted.computeBoundedTransientHeartbeatRetrySchedule);
|
|
expect(legacy.computeWorkspaceBusyRetryDelayMs).toBe(extracted.computeWorkspaceBusyRetryDelayMs);
|
|
expect(legacy.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS).toBe(extracted.BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS);
|
|
expect(legacy.WorkspaceBusyDeferral).toBe(extracted.WorkspaceBusyDeferral);
|
|
expect(deferral()).toBeInstanceOf(legacy.WorkspaceBusyDeferral);
|
|
});
|
|
|
|
it("constructs without database access or lifecycle effects", () => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
extracted.createHeartbeatRetries(database.db, deps);
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
for (const callback of Object.values(deps)) {
|
|
if (typeof callback === "function") expect(callback).not.toHaveBeenCalled();
|
|
}
|
|
for (const callback of Object.values(deps.runDispatch)) expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["missing_run", "missing_agent"] as const)("keeps %s lookup behavior and requests the full source result", async (outcome) => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
if (outcome === "missing_agent") deps.getRun.mockResolvedValue(unitRun());
|
|
expect(await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetry("run")).toEqual({ outcome });
|
|
expect(deps.getRun).toHaveBeenCalledWith("run", { unsafeFullResultJson: true });
|
|
expect(deps.getAgent).toHaveBeenCalledTimes(outcome === "missing_agent" ? 1 : 0);
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records exhaustion before escalating a plan-resume failure", async () => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
const run = unitRun();
|
|
const retry = await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetryForRun(
|
|
run, { adapterType: "codex_local" } as Agent,
|
|
{ retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, maxAttempts: 0 },
|
|
);
|
|
expect(retry).toMatchObject({ outcome: "retry_exhausted", attempt: 1, maxAttempts: 0 });
|
|
expect(deps.appendRunEvent).toHaveBeenCalledWith(run, expect.objectContaining({
|
|
retryExhaustion: { retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, scheduledRetryAttempt: 0, maxAttempts: 0 },
|
|
}));
|
|
expect(deps.escalatePlanApprovalResumeFailureNeedsAttention).toHaveBeenCalledAfter(deps.appendRunEvent);
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves successor work to the committed case handoff without retry escalation", async () => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
deps.hasCaseHandoffForRun.mockResolvedValue(true);
|
|
const run = unitRun();
|
|
const retry = await extracted.createHeartbeatRetries(database.db, deps).scheduleBoundedRetryForRun(
|
|
run, { adapterType: "codex_local" } as Agent,
|
|
{ retryReason: legacy.INTERACTION_CONTINUATION_INFRA_RETRY_REASON, maxAttempts: 0 },
|
|
);
|
|
expect(retry).toMatchObject({ outcome: "not_scheduled", issueId: "issue" });
|
|
expect(deps.hasCaseHandoffForRun).toHaveBeenCalledWith(run);
|
|
expect(deps.appendRunEvent).not.toHaveBeenCalled();
|
|
expect(deps.escalatePlanApprovalResumeFailureNeedsAttention).not.toHaveBeenCalled();
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the service's worktree cutoff and publishes only returned effects", async () => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
const now = new Date("2026-10-08T12:00:00Z");
|
|
const cutoff = new Date("2026-10-08T11:00:00Z");
|
|
const effect: PostCommitEffect = {
|
|
kind: "run_queued", runId: "retry", companyId: "company", agentId: "agent",
|
|
invocationSource: "automation", triggerDetail: "system", wakeupRequestId: "wake",
|
|
};
|
|
deps.getWorktreeExecutionCutoff.mockResolvedValue(cutoff);
|
|
deps.runDispatch.promoteDueScheduledRetries.mockResolvedValue({ promoted: 1, runIds: ["retry"], postCommitEffects: [effect] });
|
|
expect(await extracted.createHeartbeatRetries(database.db, deps).promoteDueScheduledRetries(now)).toEqual({ promoted: 1, runIds: ["retry"] });
|
|
expect(deps.runDispatch.promoteDueScheduledRetries).toHaveBeenCalledWith({ now, cutoff });
|
|
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([effect]);
|
|
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledAfter(deps.runDispatch.promoteDueScheduledRetries);
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not publish effects when promotion rejects", async () => {
|
|
const deps = callbacks();
|
|
deps.runDispatch.promoteDueScheduledRetries.mockRejectedValue(new Error("promotion failed"));
|
|
await expect(extracted.createHeartbeatRetries(guardedDatabase().db, deps).promoteDueScheduledRetries()).rejects.toThrow("promotion failed");
|
|
expect(deps.applyRunDispatchPostCommitEffects).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["workspace", "connection"] as const)("leaves a %s cancellation race winner alone", async (kind) => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
const retries = extracted.createHeartbeatRetries(database.db, deps);
|
|
if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(unitRun(), deferral());
|
|
else await retries.finalizeAiConnectionBusyDeferral(unitRun(), new HttpError(409, "busy"), true);
|
|
expect(deps.setRunStatusIfRunning).toHaveBeenCalledTimes(1);
|
|
expect(deps.setWakeupStatus).not.toHaveBeenCalled();
|
|
expect(deps.getAgent).not.toHaveBeenCalled();
|
|
expect(deps.releaseIssueExecutionAndPromote).not.toHaveBeenCalled();
|
|
expect(deps.finalizeAgentStatus).not.toHaveBeenCalled();
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["workspace", "connection"] as const)("settles the agent after a %s deferral cannot keep its issue lock", async (kind) => {
|
|
const database = guardedDatabase();
|
|
const deps = callbacks();
|
|
const run = { ...unitRun(), status: "cancelled" };
|
|
deps.setRunStatusIfRunning.mockResolvedValue({ updated: true, run });
|
|
deps.releaseIssueExecutionAndPromote.mockRejectedValue(new Error("release failed"));
|
|
const retries = extracted.createHeartbeatRetries(database.db, deps);
|
|
if (kind === "workspace") await retries.finalizeWorkspaceBusyDeferral(run, deferral());
|
|
else await expect(retries.finalizeAiConnectionBusyDeferral(run, new HttpError(409, "busy"), true)).rejects.toThrow("release failed");
|
|
expect(deps.setWakeupStatus).toHaveBeenCalledWith("wake", "cancelled", expect.any(Object));
|
|
expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(run);
|
|
expect(deps.finalizeAgentStatus).toHaveBeenCalledWith("agent", "cancelled", null, { wasFirstHeartbeat: true });
|
|
expect(deps.finalizeAgentStatus).toHaveBeenCalledAfter(deps.releaseIssueExecutionAndPromote);
|
|
expect(database.access).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_HEARTBEAT_RETRIES_TEST_DATABASE_URL");
|
|
const support = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
|
|
const describePostgres = support.supported ? describe : describe.skip;
|
|
if (!support.supported) console.warn(`Skipping retry PostgreSQL tests: ${support.reason ?? "unsupported host"}`);
|
|
|
|
describePostgres("heartbeat retry module database wiring", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
const now = new Date("2026-10-08T12:00:00Z");
|
|
|
|
beforeAll(async () => {
|
|
if (!external) database = await startEmbeddedPostgresTestDatabase("heartbeat-retries-");
|
|
db = createDb(external ?? database.connectionString, { maxConnections: 6 });
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
|
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issues);
|
|
await db.delete(heartbeatRunEvents);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agentWakeupRequests);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
function boundRetries() {
|
|
const state = createHeartbeatRunState(db);
|
|
const deps = {
|
|
...callbacks(), getRun: state.getRun, getAgent: state.getAgent,
|
|
hasCaseHandoffForRun: async (run: Pick<Run, "id" | "companyId">) => Boolean(await pendingCaseHandoffForRun(db, run.id, run.companyId)),
|
|
resolveSessionBeforeForWakeup: state.resolveSessionBeforeForWakeup,
|
|
getAgentInvokability: (agent: Agent | null | undefined) => evaluateAgentInvokabilityFromDb(db, agent),
|
|
runDispatch: createRunDispatch(db),
|
|
};
|
|
return { retries: extracted.createHeartbeatRetries(db, deps), deps };
|
|
}
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Retries", issuePrefix: `R${companyId.slice(0, 8)}` });
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Retry agent", adapterType: "codex_local", status: "active" }).returning();
|
|
const [issue] = await db.insert(issues).values({ companyId, title: "Retry task", status: "in_progress", assigneeAgentId: agent.id }).returning();
|
|
const [run] = await db.insert(heartbeatRuns).values({
|
|
companyId, agentId: agent.id, scopeKind: "issue", issueId: issue.id,
|
|
status: "failed", errorCode: "codex_transient_upstream", finishedAt: now,
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } },
|
|
contextSnapshot: { issueId: issue.id },
|
|
}).returning();
|
|
await db.update(issues).set({ executionRunId: run.id, checkoutRunId: run.id }).where(eq(issues.id, issue.id));
|
|
return { companyId, agent, issue, run };
|
|
}
|
|
|
|
it("resolves current skills on a retry while retaining the failed attempt snapshot", async () => {
|
|
const {run,agent}=await fixture();
|
|
const snapshot={...run.contextSnapshot,skillVersions:{"company/review":"v1"},skillSelectionSnapshot:true,shadowedSkills:[{key:"old",shadowedBy:"company/review"}]};
|
|
await db.update(heartbeatRuns).set({contextSnapshot:snapshot}).where(eq(heartbeatRuns.id,run.id));
|
|
await db.update(agents).set({adapterConfig:writePaperclipSkillSyncPreference({},[{key:"company/review",versionId:"v2"}])}).where(eq(agents.id,agent.id));
|
|
const result=await boundRetries().retries.scheduleBoundedRetryForRun({...run,contextSnapshot:snapshot},agent,{now,random:()=>0});
|
|
expect(result.outcome).toBe("scheduled");
|
|
const [successor]=await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId,run.id));
|
|
expect(successor!.contextSnapshot).not.toHaveProperty("skillVersions");
|
|
expect(successor!.contextSnapshot).not.toHaveProperty("skillSelectionSnapshot");
|
|
expect(successor!.contextSnapshot).not.toHaveProperty("shadowedSkills");
|
|
const [history]=await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id,run.id));
|
|
expect(history!.contextSnapshot).toEqual(snapshot);
|
|
});
|
|
|
|
it("reuses one successor across concurrent factories and keeps writes committed before callbacks", async () => {
|
|
const { run, agent, issue, companyId } = await fixture();
|
|
const first = boundRetries(), second = boundRetries();
|
|
for (const binding of [first, second]) {
|
|
binding.deps.appendRunEvent.mockImplementation(async (_source, event) => {
|
|
const retryId = event.payload?.retryRunId;
|
|
expect(typeof retryId).toBe("string");
|
|
expect(await binding.deps.getRun(retryId as string)).toMatchObject({ retryOfRunId: run.id });
|
|
});
|
|
}
|
|
const results = await Promise.all([
|
|
first.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }),
|
|
second.retries.scheduleBoundedRetryForRun(run, agent, { now, random: () => 0 }),
|
|
]);
|
|
expect(results.every((result) => result.outcome === "scheduled")).toBe(true);
|
|
const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.retryOfRunId, run.id)));
|
|
expect(successors).toHaveLength(1);
|
|
const successor = successors[0];
|
|
expect(successor).toMatchObject({
|
|
status: "scheduled_retry", issueId: issue.id, responsibleUserId: "retry-user",
|
|
scheduledRetryAttempt: 1, scheduledRetryReason: "transient_failure",
|
|
});
|
|
expect(successor.contextSnapshot).toMatchObject({ retryOfRunId: run.id, codexTransientFallbackMode: "same_session" });
|
|
expect(first.deps.resolveResponsibleUserIdForRunContext).toHaveBeenCalledWith(run, expect.objectContaining({ retryOfRunId: run.id }));
|
|
const [locked] = await db.select().from(issues).where(eq(issues.id, issue.id));
|
|
expect(locked).toMatchObject({ executionRunId: successor.id, checkoutRunId: null });
|
|
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.runId, successor.id))).toHaveLength(1);
|
|
expect(await boundRetries().retries.scheduleBoundedRetry(run.id, { now })).toMatchObject({ outcome: "scheduled", run: { id: successor.id }, reusedExisting: true });
|
|
expect(first.deps.appendRunEvent).toHaveBeenCalledTimes(1);
|
|
expect(second.deps.appendRunEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("promotes only due retries and publishes after commit", async () => {
|
|
const { run } = await fixture();
|
|
const { retries, deps } = boundRetries();
|
|
const scheduled = await retries.scheduleBoundedRetry(run.id, { now, delayMs: 60_000 });
|
|
expect(scheduled.outcome).toBe("scheduled");
|
|
if (scheduled.outcome !== "scheduled") throw new Error("Expected retry");
|
|
expect(await retries.promoteDueScheduledRetries(now)).toEqual({ promoted: 0, runIds: [] });
|
|
deps.applyRunDispatchPostCommitEffects.mockClear();
|
|
const committedStatuses: string[] = [];
|
|
const reads: Promise<void>[] = [];
|
|
deps.applyRunDispatchPostCommitEffects.mockImplementation(() => {
|
|
// Read outside the dispatch transaction after the callback is invoked.
|
|
// The assertion below waits for that read without making publication async.
|
|
reads.push(deps.getRun(scheduled.run.id).then((row) => { committedStatuses.push(row!.status); }));
|
|
});
|
|
expect(await retries.promoteDueScheduledRetries(scheduled.dueAt)).toEqual({ promoted: 1, runIds: [scheduled.run.id] });
|
|
await Promise.all(reads);
|
|
expect(committedStatuses).toEqual(["queued"]);
|
|
expect(deps.applyRunDispatchPostCommitEffects).toHaveBeenCalledWith([expect.objectContaining({ kind: "run_queued", runId: scheduled.run.id })]);
|
|
});
|
|
|
|
it("binds retry-now to the requested issue and persists its actor before promotion", async () => {
|
|
const selected = await fixture(), other = await fixture();
|
|
const { retries, deps } = boundRetries();
|
|
const scheduled = await retries.scheduleBoundedRetry(selected.run.id, { now, delayMs: 60_000 });
|
|
const foreign = await retries.scheduleBoundedRetry(other.run.id, { now, delayMs: 60_000 });
|
|
if (scheduled.outcome !== "scheduled" || foreign.outcome !== "scheduled") throw new Error("Expected retries");
|
|
expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, actor: { actorType: "user", actorId: "retry-user" }, now })).toMatchObject({
|
|
outcome: "promoted", scheduledRetry: { runId: scheduled.run.id, status: "queued" },
|
|
});
|
|
expect(await deps.getRun(scheduled.run.id)).toMatchObject({ contextSnapshot: {
|
|
retryNowRequestedByActorType: "user", retryNowRequestedByActorId: "retry-user", retryNowRequestedAt: now.toISOString(),
|
|
} });
|
|
expect(await deps.getRun(foreign.run.id)).toMatchObject({ status: "scheduled_retry", scheduledRetryAt: foreign.dueAt });
|
|
expect(await retries.retryScheduledRetryNow({ issueId: selected.issue.id, now })).toMatchObject({ outcome: "already_promoted" });
|
|
});
|
|
});
|