Files
PaperClipAI/server/src/services/task-retry.test.ts
T
DottaandPaperclip 367f29478d fix(recovery): preserve the bound remote writer before continuation (#18803)
## Thinking Path

> - Paperclip manages agent work and preserves task files.
> - A remote workspace records the exact run that owns its unsaved
files.
> - A later setup failure can become the latest task run without owning
those files.
> - Retry only inspected that latest run, so the original writer had no
file-recovery owner.
> - A released reusable source also failed the legacy retention rule.
> - This change registers the bound writer, saves its files, and then
admits the saved user request once.
> - Existing stop, ownership, approval and execution-evidence checks
remain effective.

## Linked Issues or Issue Description

**What happened?**

A native run prepared a remote workspace and failed before finalization.
Its sandbox stopped with a dedicated stop receipt. The workspace stayed
dirty and retained that run as its writer. A later setup failure became
the latest task run. Retry inspected the newer run and left the original
file obligation unregistered. New attempts failed at the writer fence.

Registration alone was insufficient. After file recovery, continuation
still required the Retry source to be one of the older recovery sources.
A newer pre-provider failure did not satisfy that check. Workspace
selection could also choose a fresh directory instead of the recovered
files.

**Expected behavior**

Find the writer through the task's current workspace binding. Register
only its exact prepared source. Preserve the released lease and its stop
receipts. Save the files before continuation. Independently prove that a
newer setup failure never dispatched work. Select the verified saved
workspace and admit the Retry once.

**Steps to reproduce**

1. Fail a native run with a prepared remote workspace and a released
reusable sandbox.
2. Keep matching dedicated stop receipts and the workspace's dirty
writer authority.
3. Record a newer pre-provider setup failure for the same task.
4. Request Retry. Before the fix, no file-maintenance record owns the
actual writer.

**Paperclip version or commit**

Reproduced before production edits on master `f7ad1dc7ca`. The branch
was rebased on master `adb49a44346849e751548b2fb640693dbfad4b6f`.

**Deployment mode**

Authenticated hosted execution with Paperclip Runner and remote
workspace storage.

Companion: #18798 (now merged upstream) fixes a pre-existing inbox test
synchronization race. Its test-only commit
`110aa88489cad926a451c972ad731ede469347c1` is cherry-picked here with
attribution so this PR can run the complete CI matrix.

Related: #17800 registers missing preservation for the selected source
run. #18678 holds unfinished remote exports. #18718 repairs a prior
warm-controller projection failure. #17916 addresses a separate legacy
warm-copyback policy case.

## What Changed

- Discover the failed writer through the company-scoped task/workspace
binding during Retry and authenticated message admission.
- Reuse the exact prepared-source validator for released remote
workspaces. Registration changes neither the lease policy nor its stop
receipts. Physical recovery retains its full allocation checks.
- Check a newer failed preparation separately before allowing an older,
repaired source to release continuation. Provider events, leases,
results and execution ownership remain disqualifying evidence.
- Select the bound saved workspace through the continuation's verified
repair receipt.
- Add PostgreSQL safety cases and an HTTP Retry workflow with real file
capture and saved-copy reads. Document the execution contract.
- Reuse #18798's completion barrier for the existing inbox cancellation
test; preserve its eight-slot limit, rejection and coalescing
assertions.

## Verification

- RED before production edits: the exact stopped writer has no
`workspaceRestoreRecovery` after Retry. Six unsafe-source controls pass
on the baseline.
- GREEN: the expanded Retry/message/preview selection passes 17 cases.
All 627 tests in the five affected recovery suites pass on recovery
commit `b572304f772d236b8a8bdf095e13e651d808f41c`, including Stop,
ownership, saved messages and duplicate Retry.
- The HTTP workflow registers maintenance, keeps execution held before
save, reads back the saved file bytes, and admits exactly one
continuation with `resume_preserved_workspace`. A registration-only
repair first failed after save because it did not reconcile the newer
Retry source. The incident-shaped pair passes with an independent active
stranded-task action present.
- Greptile identified the path where the newer setup failure already
owns a reconciliation hold. Its new regression failed before the
correction (saved files, zero queued continuations); the
contradictory-dispatch control passed. All four HTTP variants pass after
the correction, covering both hold shapes and both dispatch-evidence
controls.
- Repository typecheck and build passed. Server typecheck and build also
passed after the recovery changes. Module-boundary and whitespace checks
pass.
- The full local Vitest command failed in the GitHub fast-response
acknowledgement test (`delivery_unknown`: one response write instead of
zero). That isolated case passes on this branch and unchanged master;
the original full-run cause remains unestablished. A sibling run passed
199 test cases but timed out in workspace-suite database cleanup.
Unchanged master also passed all 126 workspace cases and timed out in
the same cleanup hook; all five acknowledgement outcomes passed in that
baseline comparison. These local failures remain recorded rather than
being reported as suite passes.
- The first hosted matrix failed the pre-existing inbox cancellation
assertion (expected nine computes, saw eight). The same assertion failed
in [master
CI](https://github.com/paperclipai/paperclip/actions/runs/41664339150/job/126015526840)
and reproduced locally before applying #18798's existing repair. All six
inbox HTTP/database cases pass after that repair. Final head
`7f2fafcbace74cb1ad8ea68c8375a69b42ed30d9` adds only that attributed
test change to the verified recovery code.
- [Final hosted
CI](https://github.com/paperclipai/paperclip/actions/runs/41677310377)
passes on head `7f2fafcbace74cb1ad8ea68c8375a69b42ed30d9`, including `ci
/ verify`, `ci / e2e`, typecheck, build, every server/chat/workspace
group, Runner checks and all eight browser shards. There are 54
successful check results, two informational neutral results and three
skipped results, with no pending or failing results.
- Greptile and the Paperclip reviewer both report 5/5 for that final
head; no review threads remain open. The Paperclip review check is
informational (`neutral`) and records its local-testing limitations.
- The file workflow uses real PostgreSQL, subprocess file capture,
encrypted saved objects and production recovery code. Provider
resume/stop calls are simulated at the environment driver boundary. This
does not prove a live Daytona or Codex recovery. No deployment or
original-task retry is claimed.

## Risks

- File-maintenance discovery must not authorize another executor.
Existing writer, coordinator and physical allocation checks still own
that decision.
- A false bootstrap summary cannot prove that a newer run never
executed. The new path also requires its immutable native-adapter claim,
unresolved preparation state, absent
coordinator/process/session/lease/result evidence, and no dispatch
events.
- Additional database reads run only during explicit task admission or
Retry strategy selection. They are company/task scoped and add no
polling loop. The saved-writer query returns one matching snapshot and
omits prompt bodies.
- No schema, credential, provider profile or deployment setting changes.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository inspection, code
execution and test tools. The runtime does not expose the exact serving
model ID or context-window size.

## 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
- [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-11-03 11:25:24 -06:00

445 lines
33 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, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issueComments, issues, issueRecoveryActions, nativeRunFinalizations, 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";
import { taskChatRetryCanAwaitRecovery, taskChatRetryExecutionStopped } from "./chat-channels/task-retry-authority.js";
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.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.each(["stopped", "running", "owned", "changed_workspace", "changed_source", "missing_stop", "destroyed", "foreign_task"].flatMap(state =>
["retry", "message"].map(entry => ({ state, entry }))).concat([{ state: "stopped", entry: "preview" }]))(
"preserves the actual remote writer behind a newer setup failure only with exact stopped authority: $entry $state", async ({ state, entry }) => {
const input = await seed();
const [latest] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.observedRunId));
const writerId = randomUUID(), workspaceId = randomUUID(), pluginId = randomUUID();
const sourceIssueId = state === "foreign_task" ? randomUUID() : input.issueId;
if (sourceIssueId !== input.issueId) await db.insert(issues).values({ id: sourceIssueId,
companyId: input.companyId, title: "Another task", status: "blocked", assigneeAgentId: latest.agentId });
await db.update(heartbeatRuns).set({ errorCode: "setup_failed", resultJson: {
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
} }).where(eq(heartbeatRuns.id, latest.id));
await db.insert(heartbeatRuns).values({ id: writerId, companyId: input.companyId, agentId: latest.agentId,
issueId: sourceIssueId, nativeIssueId: sourceIssueId, runtimeMode: "native",
status: state === "running" ? "running" : "failed", finishedAt: state === "running" ? null : new Date(),
createdAt: new Date(latest.createdAt.getTime() - 60_000),
contextSnapshot: { issueId: sourceIssueId, executionWorkspaceId: workspaceId },
resultJson: { workspaceRestoreFailure: "restore_failed", originalDiagnostic: true },
});
await db.insert(nativeRunFinalizations).values({ companyId: input.companyId, issueId: sourceIssueId,
runId: writerId, phase: "terminal_failure", leaseOwner: state === "owned" ? "live-controller" : null });
const [environment] = await db.insert(environments).values({ name: `Stopped source ${writerId}`, driver: "sandbox" }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId: input.companyId, issueId: sourceIssueId,
heartbeatRunId: writerId, environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(),
status: "released", cleanupStatus: "success", leasePolicy: "reuse_by_environment", acquiredAt: new Date(Date.now() - 60_000),
releasedAt: new Date(),
}).returning();
const confirmedAt = lease.releasedAt!.toISOString();
const identity = { companyId: input.companyId, runId: writerId, leaseId: lease.id,
provider: lease.provider, providerLeaseId: lease.providerLeaseId, confirmedAt };
const metadata = { driver: "sandbox", pluginId, sandboxProviderPlugin: true, remoteCwd: "/work",
...(state === "missing_stop" ? {} : {
remoteExecutionTermination: { ...identity, schema: "paperclip.remote-termination.v1", state: state === "destroyed" ? "destroyed" : "stopped" },
sandboxStopAndRetainReceipt: { ...identity, schema: "paperclip.sandbox-stop-and-retain-receipt.v1",
pluginId, method: "environmentStopLease", requestId: randomUUID() },
}),
};
await db.update(environmentLeases).set({ metadata }).where(eq(environmentLeases.id, lease.id));
const reference = { schema: "paperclip.remote-workspace-run.v1", workspaceId, epoch: 3,
snapshotId: randomUUID(), leaseId: lease.id, providerLeaseId: lease.providerLeaseId, remoteCwd: "/work", state: "prepared" };
await db.update(heartbeatRuns).set({ runnerProfileJson: { remoteWorkspace: {
...reference, ...(state === "changed_source" ? { providerLeaseId: randomUUID() } : {}),
} } }).where(eq(heartbeatRuns.id, writerId));
const authority = { schema: "paperclip.remote-workspace.v1", epoch: state === "changed_workspace" ? 4 : 3,
snapshotId: reference.snapshotId, source: { kind: "empty" }, writerRunId: writerId, dirty: true,
location: { environmentId: environment.id, leaseId: lease.id, providerLeaseId: lease.providerLeaseId, remoteCwd: "/work" } };
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId: input.companyId, sourceIssueId,
name: "Unfinished remote writer", mode: "isolated_workspace", strategyType: "project_primary", cwd: "/work",
providerType: "cloud_sandbox", metadata: { remoteWorkspace: authority } });
await db.update(environmentLeases).set({ executionWorkspaceId: workspaceId }).where(eq(environmentLeases.id, lease.id));
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, input.issueId));
if (entry === "retry") {
const receipt = await requestTaskRetry(input);
expect((await requestTaskRetry(input)).requestId).toBe(receipt.requestId);
} else {
const [comment] = await db.insert(issueComments).values({ companyId: input.companyId, issueId: input.issueId,
authorType: "user", authorUserId: input.actorId, body: "Continue from saved work.",
createdAt: new Date() }).returning();
for (let attempt = 0; attempt < 2; attempt++) await db.transaction(tx => admitExplicitNativeContinuation({
db: tx as unknown as typeof db, companyId: input.companyId, issueId: input.issueId, agentId: latest.agentId,
actorType: "user", actorId: input.actorId, commentId: comment.id, successorRunId: randomUUID(),
reason: "issue_commented", dryRun: entry === "preview",
}));
}
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, writerId));
const repairs = (await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, input.issueId)))
.filter(action => action.evidence.workspaceRestoreRecovery);
if (state === "stopped" && entry !== "preview") {
expect(source.resultJson).toMatchObject({ originalDiagnostic: true,
workspaceRestoreRecovery: { leaseIds: [lease.id] } });
expect(repairs).toHaveLength(1);
expect(repairs[0].evidence.runId).toBe(writerId);
} else {
expect(source.resultJson).not.toHaveProperty("workspaceRestoreRecovery");
expect(repairs).toHaveLength(0);
}
expect((await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)))[0])
.toMatchObject({ status: "released", leasePolicy: "reuse_by_environment", metadata });
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)))[0].metadata?.remoteWorkspace)
.toEqual(authority);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, input.companyId))).toHaveLength(2);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, input.companyId))).toHaveLength(entry === "retry" ? 1 : 0);
});
it.each(["prepared", "mismatched", "owned"] as const)("reconstructs missing native preservation on deliberate Retry only for an exact unowned source: %s", async state => {
const input = await seed();
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
const [environment] = await db.insert(environments).values({ name: input.observedRunId, driver: "sandbox" }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId: input.companyId, issueId: input.issueId,
heartbeatRunId: input.observedRunId, environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(),
status: "active", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work" },
}).returning();
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: input.issueId,
nativePhase: "terminal_failure", errorCode: "execution_finalization_deadline_exceeded", runnerProfileJson: {
nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v3", state: "prepared",
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null,
workspaceId: input.observedRunId, leaseId: lease.id,
providerLeaseId: state === "mismatched" ? randomUUID() : lease.providerLeaseId,
remoteCwd: "/work", resourceDisposition: "keep_running" },
} }).where(eq(heartbeatRuns.id, input.observedRunId));
await db.insert(nativeRunFinalizations).values({ companyId: input.companyId, issueId: input.issueId,
runId: input.observedRunId, phase: "terminal_failure", leaseOwner: state === "owned" ? "live-controller" : null });
await db.insert(issueRecoveryActions).values({ companyId: input.companyId, sourceIssueId: input.issueId,
kind: "active_run_watchdog", status: "resolved", ownerType: "board",
cause: "execution_finalization_deadline_exceeded", fingerprint: randomUUID(), outcome: "blocked",
nextAction: "Inspect the failed run.", evidence: { runId: input.observedRunId,
providerOwnership: "unverified", automaticRecovery: { replay: "blocked", actionOutcome: "unknown" } } });
const receipt = await requestTaskRetry(input);
expect((await requestTaskRetry(input)).requestId).toBe(receipt.requestId);
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.observedRunId));
const [retained] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id));
const repairs = (await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, input.issueId)))
.filter(action => action.evidence.workspaceRestoreRecovery);
if (state === "prepared") {
expect(source.resultJson).toMatchObject({ workspaceRestoreRecovery: { leaseIds: [lease.id] } });
expect(retained).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect(repairs).toHaveLength(1);
expect(readSystemRecovery(repairs[0].evidence)).toMatchObject({ state: "scheduled", intentId: receipt.requestId });
expect(await taskChatRetryCanAwaitRecovery(db, source, input.issueId, receipt.requestId!)).toBe(true);
} else {
expect(source.resultJson).toBeNull();
expect(retained).toMatchObject({ status: "active", leasePolicy: "reuse_by_environment" });
expect(repairs).toHaveLength(0);
}
expect(await taskChatRetryExecutionStopped(db, source)).toBe(false);
expect(retained.metadata).not.toHaveProperty("remoteExecutionTermination");
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, input.companyId))).toHaveLength(1);
expect((await db.select().from(issues).where(eq(issues.id, input.issueId)))[0])
.toMatchObject({ status: "blocked", assigneeAgentId: issue.assigneeAgentId, executionRunId: null, checkoutRunId: null });
});
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" });
});
});