Files
PaperClipAI/server/src/services/task-retry.test.ts
T
DottaandPaperclip d33b40a5c3 fix: recover saved work and provide reliable task Retry (#16965)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The control plane must retain work when an execution or workspace
export fails.
> - Retained-workspace holds can leave saved input waiting with no
usable Retry action.
> - Users cannot verify process ownership or repair provider
allocations.
> - This pull request gives system recovery a durable budget and gives
each deliberate Retry a durable intent.
> - Safe recovery can preserve files, settle an accepted result, or
prepare an independent workspace.
> - One task state keeps infrastructure diagnostics out of the ordinary
conversation while preserving real permission and approval gates.

## Linked Issues or Issue Description

**What happened?**

A task with a retained workspace can show recovery, failed-run and
saved-message warnings at the same time. Historical runs without native
workspace descriptors cannot use the native restore path. Old recovery
metadata can prevent a new attempt even when an independent workspace
would be safe.

**Expected behavior**

Paperclip owns bounded infrastructure retries and file preservation. A
user can request another attempt with ordinary Retry. Thinking appears
immediately. The service preserves completed effects and current user
controls. It does not claim that starting fresh recovered the old files.

**Steps to reproduce**

1. Retain a failed run and its workspace after export or process-stop
failure.
2. Save a follow-up while the recovery hold is active.
3. Open the task and try to continue without infrastructure credentials
or operator certification.

Refs #16906, #16835, #16883. This extends their recovery and receipt
contracts. Related work: #16962 and #16963.

## What Changed

- Add task-level Retry with company-scoped authorization, all-status
idempotency and durable admission before physical work. Keep exact-run
Retry compatible.
- Add typed recovery phases, finite persisted backoff, historical
adoption and durable local failure diagnostics.
- Preserve native workspaces or verified historical snapshots. Admit an
independent fresh workspace only after execution containment. Keep
old-source preservation and cleanup obligations separate.
- Carry completed publication and API receipts forward. Fence uncertain
mutation retries across replacement runs. Preserve connected-chat
identity and authorization.
- Re-enter normal dispatch for saved input. Reuse accepted results
through the existing finalizer.
- Use one task interruption projection across the thread, composer and
task list. Show optimistic Thinking, ordinary Retry and genuine user
gates.
- Add PostgreSQL, filesystem, API, browser-fixture and controlled
provider coverage. Document the behavior and update the feature/access
maps.

## Verification

- All GitHub Actions CI jobs pass on
`6d8c6d27d50586d8548b31d03b47f6d73a0ee11d`: typecheck, all
server/chat/workspace and serialized suites, Runner checks, eight
browser shards, canary and build. The required merge gate passes.
- Greptile reviewed the final commit at 5/5, with no actionable findings
or unresolved review threads. Its two earlier P1 findings are fixed.
- Full recursive typecheck and build pass locally on the final commit.
All 16 task Retry cases pass against isolated real PostgreSQL. The
unrelated-success case and both stale Retry cases fail on the previous
code and pass after the fix.
- The corrected gateway fixture passes its uncertain-mutation and
independent-read case. Four real HTTP API cases pass for definite
403/422 rejection, completed-effect reuse, and uncertain-effect fences
across deliberate and automatic retries. These focused gateway/API
checks used a temporary database lifecycle harness on an existing
healthy local PostgreSQL process. The standard repository helper is
unchanged.
- Earlier qualification covers 291 task UI cases, saved-file copyback
and ancestry, current Agent Chat ownership races, nine chat Retry API
browser cases, and six assembled browser recovery fixtures. A real
API/PostgreSQL/native-runner journey showed Thinking in 25 ms despite a
delayed response, admitted one successor in a separate workspace,
retained original files, and survived reload.
- The controlled Daytona test interrupts export, joins the controller,
restarts it in a separate process, and verifies one agent start, one
external sentinel operation, retained file contents and no premature
cleanup. It does not establish abrupt-kill or live LLM/GitHub
acceptance.
- The full local `pnpm test:run` was attempted and was not green: an
OAuth row-lock timing assertion failed, and a later suite exhausted
macOS PostgreSQL semaphores. The ten OAuth timing cases passed on rerun
against a separate database. Later embedded database initialization
failures remain recorded; the final CI matrix passes.
- Security scanning passed on production-code commit
`63953edf069b111c6becc3144dffa6deb52f68b5`. Its only successor changes
one test-fixture identity line. The final scan is neutral/inconclusive
because its pinned merge-base checkout failed or timed out. Both rerun
API requests returned 404, so no fresh successful scan is claimed.
Socket and Snyk checks pass on the final head.
- The optional Paperclip Review remains queued behind an earlier run in
workspace finalization. Its prior completed assessment was incomplete
because a comparison tool was blocked by the platform filter. It is not
counted as a completed code review. GitHub does not require this check
for merge.
- Hosted acceptance remains pending deployment. Local and CI results do
not establish recovery of a hosted task or its retained files.

## Risks

- Recovery changes span admission, physical ownership and dispatch.
Incorrect evidence could permit overlapping work. Existing physical
locks and authoritative stop requirements remain in place.
- The migration adds concurrent indexes. It requires the existing
top-level migration runner and retries incomplete index creation safely.
- A fresh attempt can complete while old-source preservation remains
unresolved. The service retains that obligation and does not report
those files as recovered.
- Unknown tool mutations remain blocked until their authoritative
receipt owner resolves them. Generic upstream tools have no universal
result-query protocol.
- Recovery and diagnostics stay inside the stack. No Cloud incident
endpoint, notification worker or new outbound reporting channel is
included.

## Model Used

OpenAI Codex, GPT-6 family. The runtime does not expose a more specific
model version or context-window size. The model used reasoning, shell
execution, source editing and browser tools. No model subagents were
used for this implementation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites; the full
local run limitation is documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 19:58:53 -05:00

312 lines
23 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, agentWakeupRequests, companies, createDb, heartbeatRuns, issues, issueRecoveryActions, toolApplications, toolConnections, toolInvocations, applyPendingMigrations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { priorRecoveryApiReceipt, unresolvedRecoveryToolInvocation } from "./recovery-effect-receipts.js";
import { requestTaskRetry } from "./task-retry.js";
import { readFreshWorkspaceReadiness } from "./fresh-workspace-continuation.js";
import { settleUnrecoverableExecutions } from "./execution-recovery-resolution.js";
import { createSystemRecovery, failSystemRecovery, readSystemRecovery, SystemRecoveryFailure } from "./system-recovery-state.js";
import { instanceSettingsService } from "./instance-settings.js";
import { heartbeatService } from "./heartbeat.js";
describe("durable task Retry admission", () => {
let postgres: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
const externalUrl = process.env.PAPERCLIP_TASK_RETRY_TEST_DATABASE_URL;
if (externalUrl) {
await applyPendingMigrations(externalUrl);
db = createDb(externalUrl);
} else {
postgres = await startEmbeddedPostgresTestDatabase("task-retry-");
db = createDb(postgres.connectionString);
}
}, 30_000);
afterAll(async () => { await db?.$client.end(); await postgres?.cleanup(); });
async function seed() {
const companyId = randomUUID(), issueId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Retry company", issuePrefix: `T${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "idle" });
await db.insert(issues).values({ id: issueId, companyId, title: "Continue saved work", status: "blocked", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed",
contextSnapshot: { issueId }, finishedAt: new Date() });
return { db, companyId, issueId, actorId: "board", observedRunId: runId, idempotencyKey: randomUUID() };
}
it("serializes concurrent clicks and remembers each key after completion", async () => {
const input = await seed();
const second = { ...input, idempotencyKey: randomUUID() };
const [one, two] = await Promise.all([requestTaskRetry(input), requestTaskRetry(second)]);
expect(one.requestId).toBe(two.requestId);
const rows = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId));
expect(rows.filter(row => row.status === "deferred_issue_execution")).toHaveLength(1);
expect(rows).toHaveLength(2);
await db.update(agentWakeupRequests).set({ status: "failed" }).where(eq(agentWakeupRequests.id, one.requestId!));
expect((await requestTaskRetry(input)).requestId).toBe(one.requestId);
expect((await requestTaskRetry(second)).requestId).toBe(one.requestId);
const next = await requestTaskRetry({ ...input, idempotencyKey: randomUUID() });
expect(next.requestId).not.toBe(one.requestId);
});
it("does not resurrect completed tasks or retry an obsolete assignment", async () => {
const input = await seed();
await db.update(issues).set({ status: "done" }).where(eq(issues.id, input.issueId));
expect(await requestTaskRetry(input)).toMatchObject({ accepted: false, stale: true });
await db.update(issues).set({ status: "blocked", assigneeAgentId: null }).where(eq(issues.id, input.issueId));
expect(await requestTaskRetry(input)).toMatchObject({ accepted: false, stale: true });
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId))).toHaveLength(0);
});
it("requires current Agent Chat ownership and enablement even when replaying a retry receipt", async () => {
const input = await seed();
const settings = instanceSettingsService(db);
await settings.updateExperimental({ enableAgentChat: true });
await db.update(issues).set({ conversationAgentId: (await db.select().from(issues).where(eq(issues.id, input.issueId)))[0].assigneeAgentId,
conversationUserId: input.actorId, conversationState: "waiting" }).where(eq(issues.id, input.issueId));
try {
await expect(requestTaskRetry({ ...input, actorId: "another-user" })).rejects.toMatchObject({ status: 403 });
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId))).toHaveLength(0);
const accepted = await requestTaskRetry(input);
expect(accepted.accepted).toBe(true);
await settings.updateExperimental({ enableAgentChat: false });
await expect(requestTaskRetry(input)).rejects.toMatchObject({ status: 404 });
await settings.updateExperimental({ enableAgentChat: true });
await db.update(issues).set({ conversationUserId: "new-owner" }).where(eq(issues.id, input.issueId));
await expect(requestTaskRetry(input)).rejects.toMatchObject({ status: 403 });
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId))).toHaveLength(1);
const next = await requestTaskRetry({ ...input, actorId: "new-owner", idempotencyKey: randomUUID() });
expect(next.requestId).not.toBe(accepted.requestId);
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, accepted.requestId!)))[0].status).toBe("failed");
} finally { await settings.updateExperimental({ enableAgentChat: false }); }
});
it.each(["owner", "feature"] as const)("rechecks Agent Chat $0 at dispatch after waiting for the issue lock", async gate => {
const input = { ...await seed(), actorId: "local-board", actorSource: "local_implicit" };
const settings = instanceSettingsService(db);
await settings.updateExperimental({ enableAgentChat: true, enableNativeRunner: true });
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
await db.update(issues).set({ conversationAgentId: issue.assigneeAgentId, conversationUserId: input.actorId,
conversationState: "waiting" }).where(eq(issues.id, input.issueId));
// Keep admitted runs queued: this test asserts admission, not provider behavior.
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } })
.where(eq(agents.id, issue.assigneeAgentId!));
await db.insert(heartbeatRuns).values({ companyId: input.companyId, agentId: issue.assigneeAgentId!, status: "running" });
const receipt = await requestTaskRetry(input);
let release!: () => void, ready!: () => void, holderPid = 0;
const held = new Promise<void>(resolve => { ready = resolve; });
const unlock = new Promise<void>(resolve => { release = resolve; });
const holder = db.transaction(async tx => {
holderPid = (await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0].pid;
await tx.select().from(issues).where(eq(issues.id, issue.id)).for("update");
ready(); await unlock;
if (gate === "owner") await tx.update(issues).set({ conversationUserId: "new-owner" }).where(eq(issues.id, issue.id));
else await instanceSettingsService(tx as unknown as typeof db).updateExperimental({ enableAgentChat: false });
});
let dispatch: Promise<unknown> | undefined;
try {
await held;
dispatch = heartbeatService(db).resumeTaskRetry(input.companyId, receipt.requestId!).catch(error => error);
await expect.poll(async () => (await db.execute<{ waiting: boolean }>(sql`select exists(
select 1 from pg_stat_activity where datname = current_database()
and ${holderPid} = any(pg_blocking_pids(pid))) as waiting`))[0].waiting, { timeout: 5_000 }).toBe(true);
release(); await holder;
expect(await dispatch).toMatchObject({ status: gate === "owner" ? 403 : 404 });
expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.status, "queued")))).toHaveLength(0);
// Restore current authority; the same saved receipt can then admit one run.
await settings.updateExperimental({ enableAgentChat: true });
await db.update(issues).set({ conversationUserId: input.actorId }).where(eq(issues.id, issue.id));
await heartbeatService(db).resumeTaskRetry(input.companyId, receipt.requestId!);
expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.status, "queued")))).toHaveLength(1);
} finally {
release(); await Promise.allSettled([holder, dispatch]);
await settings.updateExperimental({ enableAgentChat: false, enableNativeRunner: false });
}
}, 30_000);
it("replaces an exhausted unadmitted generation only on a new deliberate click", async () => {
const input = await seed();
const first = await requestTaskRetry(input);
const state = failSystemRecovery(createSystemRecovery(input.observedRunId, first.requestId!),
new SystemRecoveryFailure("source_unavailable", "service_required"));
const [action] = await db.insert(issueRecoveryActions).values({
companyId: input.companyId, sourceIssueId: input.issueId, kind: "active_run_watchdog",
ownerType: "system", cause: "legacy_execution_requires_reconciliation", fingerprint: randomUUID(),
nextAction: "System recovery", evidence: { runId: input.observedRunId, systemRecovery: state },
}).returning();
expect((await requestTaskRetry(input)).requestId).toBe(first.requestId);
const nextInput = { ...input, idempotencyKey: randomUUID() };
const next = await requestTaskRetry(nextInput);
expect(next.requestId).not.toBe(first.requestId);
const [updated] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id));
expect(readSystemRecovery(updated.evidence)).toMatchObject({ generationId: next.requestId, attempts: 0 });
expect((await requestTaskRetry(nextInput)).requestId).toBe(next.requestId);
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId));
expect(wakes.find(row => row.id === first.requestId)?.status).toBe("failed");
expect(wakes.filter(row => row.status === "deferred_issue_execution")).toHaveLength(1);
});
it("binds a key to its company, task and actor across all statuses", async () => {
const input = await seed();
const receipt = await requestTaskRetry(input);
await db.update(agentWakeupRequests).set({ status: "completed" }).where(and(
eq(agentWakeupRequests.companyId, input.companyId), eq(agentWakeupRequests.id, receipt.requestId!)));
await expect(requestTaskRetry({ ...input, actorId: "another-user" })).rejects.toThrow("different task or user");
expect((await requestTaskRetry(input)).requestId).toBe(receipt.requestId);
const other = await seed();
expect((await requestTaskRetry({ ...other, idempotencyKey: input.idempotencyKey })).requestId).not.toBe(receipt.requestId);
});
it("keeps an adopted historical hold with its maintenance owner", async () => {
const input = await seed();
const system = createSystemRecovery(input.observedRunId);
const [action] = await db.insert(issueRecoveryActions).values({
companyId: input.companyId, sourceIssueId: input.issueId, kind: "active_run_watchdog",
status: "active", ownerType: "system", returnOwnerAgentId: null,
cause: "legacy_execution_requires_reconciliation", fingerprint: randomUUID(),
nextAction: "System preservation", evidence: { runId: input.observedRunId, systemRecovery: system },
}).returning();
await settleUnrecoverableExecutions(db);
const [current] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, action.id));
expect(current.status).toBe("active");
expect(current.ownerType).toBe("system");
expect(current.evidence.systemRecovery).toEqual(system);
expect(current.evidence.automaticRecovery).toBeUndefined();
});
it.each(["evaluate", "continue"])("keeps an admitted %s retry isolated after a failure without a preserved workspace", async strategy => {
const input = await seed();
const receipt = await requestTaskRetry(input);
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.observedRunId));
const [successor] = await db.insert(heartbeatRuns).values({ companyId: input.companyId,
agentId: source.agentId, issueId: input.issueId, status: "queued",
contextSnapshot: { issueId: input.issueId, taskRetryRequestId: receipt.requestId } }).returning();
expect(await readFreshWorkspaceReadiness(db, successor)).toBeNull();
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!));
await db.update(agentWakeupRequests).set({ runId: successor.id, status: "coalesced",
payload: { ...wake.payload, taskRetry: { ...(wake.payload!.taskRetry as object), strategy } } })
.where(eq(agentWakeupRequests.id, receipt.requestId!));
const readiness = await readFreshWorkspaceReadiness(db, successor);
expect(readiness).toMatchObject({ mode: "fresh_isolated_workspace", retryIntentId: receipt.requestId,
sourceRunId: source.id });
expect(await readFreshWorkspaceReadiness(db, successor)).toEqual(readiness);
expect(await readFreshWorkspaceReadiness(db, { ...successor, id: randomUUID() })).toBeNull();
});
it("reuses completed API effects only from the retry ancestry while retaining unrelated uncertainty", async () => {
const input = await seed();
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.observedRunId));
const receipt = { operationId: "PATCH /api/issues/{id}", digest: "same-title", state: "completed",
result: { ok: true, status: 200, data: { title: "Earlier title" } } };
const [earlier] = await db.insert(heartbeatRuns).values({ companyId: input.companyId, issueId: input.issueId,
agentId: source.agentId, status: "succeeded", createdAt: new Date(1_000), resultJson: { apiToolReceipts: { receipt } } }).returning();
const accepted = await requestTaskRetry(input);
const [successor] = await db.insert(heartbeatRuns).values({ companyId: input.companyId, issueId: input.issueId,
agentId: source.agentId, status: "running", createdAt: new Date(Date.now() + 1_000) }).returning();
await db.update(agentWakeupRequests).set({ status: "coalesced", runId: successor.id })
.where(eq(agentWakeupRequests.id, accepted.requestId!));
const call = { companyId: input.companyId, issueId: input.issueId, runId: successor.id,
operationId: receipt.operationId, digest: receipt.digest };
expect(await priorRecoveryApiReceipt(db, call)).toBeNull();
// An actual predecessor edge makes the exact receipt relevant, even across multiple attempts.
await db.update(heartbeatRuns).set({ retryOfRunId: earlier.id }).where(eq(heartbeatRuns.id, source.id));
expect(await priorRecoveryApiReceipt(db, call)).toEqual(receipt);
await db.update(heartbeatRuns).set({ retryOfRunId: null }).where(eq(heartbeatRuns.id, source.id));
await db.update(heartbeatRuns).set({ resultJson: { apiToolReceipts: { receipt: { ...receipt,
state: "pending", result: { ok: false, outcome: "unknown" } } } } }).where(eq(heartbeatRuns.id, earlier.id));
expect(await priorRecoveryApiReceipt(db, call)).toMatchObject({ state: "pending", result: { outcome: "unknown" } });
});
async function automaticSuccessorOfTaskRetry() {
const input = await seed();
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.observedRunId));
const intent = await requestTaskRetry(input);
const [retry] = await db.insert(heartbeatRuns).values({ companyId: input.companyId, issueId: input.issueId,
agentId: source.agentId, runtimeMode: "native", status: "failed", createdAt: new Date(Date.now() + 1_000) }).returning();
await db.update(agentWakeupRequests).set({ status: "coalesced", runId: retry.id })
.where(eq(agentWakeupRequests.id, intent.requestId!));
const [automatic] = await db.insert(heartbeatRuns).values({ companyId: input.companyId, issueId: input.issueId,
agentId: source.agentId, runtimeMode: "native", status: "running", retryOfRunId: retry.id,
createdAt: new Date(Date.now() + 2_000) }).returning();
await db.insert(agentWakeupRequests).values({ companyId: input.companyId, agentId: source.agentId,
runId: automatic.id, source: "automation", reason: "transient_failure", status: "coalesced",
requestedByActorType: "system", payload: { issueId: input.issueId, retryOfRunId: retry.id } });
return { input, retry, automatic };
}
it.each(["completed", "pending"] as const)("automatic successors inherit %s API receipts after task Retry", async state => {
const { input, retry, automatic } = await automaticSuccessorOfTaskRetry();
const receipt = { operationId: "POST /api/companies/{companyId}/projects", digest: "same-project", state,
result: state === "completed" ? { ok: true, status: 201 } : { ok: false, outcome: "unknown" } };
await db.update(heartbeatRuns).set({ resultJson: { apiToolReceipts: { receipt } } }).where(eq(heartbeatRuns.id, retry.id));
const call = { companyId: input.companyId, issueId: input.issueId, runId: automatic.id,
operationId: receipt.operationId, digest: receipt.digest };
expect(await priorRecoveryApiReceipt(db, call)).toMatchObject({ state, result: receipt.result });
// A transcript predecessor is insufficient; the persisted run edge owns lineage.
await db.update(heartbeatRuns).set({ retryOfRunId: null, contextSnapshot: { previousRunId: retry.id } })
.where(eq(heartbeatRuns.id, automatic.id));
expect(await priorRecoveryApiReceipt(db, call)).toBeNull();
});
it("automatic successors retain uncertain upstream mutation fences after task Retry", async () => {
const { input, retry, automatic } = await automaticSuccessorOfTaskRetry();
const [application] = await db.insert(toolApplications).values({ companyId: input.companyId,
name: "Test API", type: "rest_api" }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId: input.companyId, applicationId: application.id,
name: "Test API", uid: randomUUID(), transport: "rest_api" }).returning();
const [invocation] = await db.insert(toolInvocations).values({ companyId: input.companyId, issueId: input.issueId,
runId: retry.id, connectionId: connection.id, toolName: "create_project", riskLevel: "write",
status: "timed_out", startedAt: new Date() }).returning();
const call = { companyId: input.companyId, issueId: input.issueId, runId: automatic.id,
connectionId: connection.id, toolName: "create_project", risk: "write" };
expect(await unresolvedRecoveryToolInvocation(db, call)).toBe(invocation.id);
expect(await unresolvedRecoveryToolInvocation(db, { ...call, risk: "read" })).toBeNull();
});
it("retires a deferred Retry when newer work appears while dispatch waits for the issue lock", async () => {
const input = { ...await seed(), actorId: "local-board", actorSource: "local_implicit" };
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true });
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
await db.update(agents).set({ runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } })
.where(eq(agents.id, issue.assigneeAgentId!));
await db.insert(heartbeatRuns).values({ companyId: input.companyId, agentId: issue.assigneeAgentId!, status: "running" });
const receipt = await requestTaskRetry(input);
let release!: () => void, ready!: () => void, holderPid = 0;
const held = new Promise<void>(resolve => { ready = resolve; });
const unlock = new Promise<void>(resolve => { release = resolve; });
const newerRunId = randomUUID();
const holder = db.transaction(async tx => {
holderPid = (await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`))[0].pid;
await tx.select().from(issues).where(eq(issues.id, issue.id)).for("update");
ready(); await unlock;
await tx.insert(heartbeatRuns).values({ id: newerRunId, companyId: input.companyId, issueId: issue.id,
agentId: issue.assigneeAgentId!, status: "succeeded", finishedAt: new Date(), createdAt: new Date(Date.now() + 1_000) });
await tx.update(issues).set({ status: "in_progress", executionRunId: newerRunId }).where(eq(issues.id, issue.id));
});
let dispatch: Promise<unknown> | undefined;
try {
await held;
dispatch = heartbeatService(db).resumeTaskRetry(input.companyId, receipt.requestId!);
await expect.poll(async () => (await db.execute<{ waiting: boolean }>(sql`select exists(
select 1 from pg_stat_activity where datname = current_database()
and ${holderPid} = any(pg_blocking_pids(pid))) as waiting`))[0].waiting, { timeout: 5_000 }).toBe(true);
release(); await holder; await dispatch;
await heartbeatService(db).resumeTaskRetry(input.companyId, receipt.requestId!);
expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.status, "queued")))).toHaveLength(0);
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!)))[0])
.toMatchObject({ status: "skipped", runId: null, error: "task_retry_superseded" });
} finally {
release(); await Promise.allSettled([holder, dispatch]);
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: false });
}
}, 30_000);
it("does not attach a new deliberate Retry to an unadmitted request for an older run", async () => {
const input = await seed();
const old = await requestTaskRetry(input);
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
const [newer] = await db.insert(heartbeatRuns).values({ companyId: input.companyId, issueId: input.issueId,
agentId: issue.assigneeAgentId!, status: "failed", finishedAt: new Date(), createdAt: new Date(Date.now() + 1_000) }).returning();
const next = await requestTaskRetry({ ...input, observedRunId: newer.id, idempotencyKey: randomUUID() });
expect(next.requestId).not.toBe(old.requestId);
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, old.requestId!)))[0])
.toMatchObject({ status: "skipped", error: "task_retry_superseded" });
});
});