Files
PaperClipAI/server/src/services/native-runtime/native-failed-workspace-recovery.test.ts
T
DottaandPaperclip 6bec153410 fix(runtime): retain cancelled workspaces before reconstruction (#16700)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs must preserve files when Stop ends a turn before its
result is saved.
> - The cancellation path could skip file retention and leave a prepared
remote workspace active.
> - An older controller could also admit an extra run while an earlier
lost-source hold was unresolved.
> - Rejecting every later run then prevented recovery even after that
extra run was stopped and its files were reconciled.
> - This change retains cancelled workspaces and records the exact proof
for a safely reconciled intervening cancellation.
> - Recovery can proceed without changing cancelled history, replaying
actions, or weakening execution gates.

## Linked Issues or Issue Description

Refs: #16583, #16690, #16696.

**What happened?**

Stop could acknowledge a native turn without recording its unsaved file
obligation. Reconstruction also rejected a later cancelled run after its
exact environment and files had been reconciled. The task could remain
inactive with an `in_progress` status, which the reconstruction endpoint
rejected.

**Expected behavior**

Retain unsaved files after Stop. Permit a board operator to reconstruct
from saved sources only after each intervening cancellation has a
confirmed stop and a file-only repair record. Preserve the original
failed export and the cancelled run.

**Steps to reproduce**

1. Prepare a native workspace and cancel the turn before a structured
result exists.
2. Inspect its file retention and remote lease state.
3. For a task with an older destroyed export, reconcile the later
cancelled run's exact files and unperformed actions.
4. Request reconstruction from verified same-task attachments.

## What Changed

- Pin prepared native files before cancellation settlement. Defer
teardown if pinning fails. Use the existing stop-only cleanup worker.
- Repair active warm and ephemeral sources, plus stopping and
confirmed-stopped historical cancellations in bounded startup and
periodic batches. Preserve the existing exact-source checks. Bound
row-lock waits and advance past a busy source.
- Accept only same-owner native run-only cancellations with no result or
current coordinator, confirmed stopped leases, and board file-only
repair with `not_performed` action outcomes.
- Save hashes of each intervening repair and recheck them at queue claim
and final dispatch. Reject changed evidence or another later run.
- Expose saved-source reconstruction in the issue API for inactive
`blocked` and `in_progress` tasks, and hide it when an execution or
checkout owner exists. Permit the same inactive source at submission.
Keep the source, owner, version, attachment, budget, pause and retry
checks.
- Update the shared evidence type, recovery runbook and capability maps.
Add an optional cancelled-native no-action confirmation to file
recovery, resetting evidence when its source changes. No migration, new
endpoint, permission or dependency.

## Verification

- The new fixture fails on the original validator with
`workspace_reconstruction_stale` after valid cancelled-run file repair.
- Focused source and Stop retention tests: 94 passed before the final
contention case. The subsequent three-suite run passed 123 tests,
including real PostgreSQL lock contention and public
startup/API/final-dispatch paths. The final narrow-query rerun passed
all 123 tests. Review correction: all 143 tests passed across four
suites, including real lock contention through the teardown boundary and
stopping/stopped allocations.
- Six broader cancellation, executor, heartbeat and restart suites: 831
passed, 9 existing skips.
- Final correction: all 145 backend tests and all 36 recovery UI tests
passed. Real contention covers both warm reusable and ephemeral sources.
Optional no-action confirmation defaults to uncertified, and changing
source clears evidence. Workspace `pnpm -r typecheck` and token gates
passed on these sources.
- Maps, module boundaries and Git whitespace checks: passed. Full `pnpm
build` passed on the final `4e8c0e73ac` revision as well as the prior
reviewed heads. A full local run of the preceding fix completed with
21,724 passes, 109 skips and three failures in webhook body limits, a
status-card timeout, and a port-allocation fixture. All three suites
then passed unchanged in an isolated rerun (209 tests). The full local
run remains recorded as failed; the final head's full CI passed (55
completed checks, no failures). A fresh full local run is also in
progress on the frozen reviewed head.
- Preserved failures: the first attempt lacked a built plugin SDK; after
building prerequisites, the regression reached the expected validator
rejection. Treatment tests caught the file-only action's deliberately
null continuation owner. A real lock test then caught the database
driver's wrapped timeout error. Both defects were corrected and
retested. Independent review then found a teardown race after a failed
retention write. A second review found active warm sources excluded by
the historical policy filter; the regression reproduced both warm
failures before correcting the query. Its real-row-lock fixture
reproduced the failure; the fixed release guard and historical
stopping/stopped cases pass. No checks were weakened.
- A pre-deployment walkthrough caught the issue API still hiding
reconstruction for an inactive `in_progress` task. The status/capability
baseline reproduced three failures, including inappropriate capability
flags for execution/checkout owners. The fix uses the same
inactive-state requirements as submission; its positive fixture reads
the assembled issue API before reconstructing. All 117 tests in those
three recovery suites pass, and all 28 heartbeat-recovery tests pass
separately. Full workspace typecheck passed again. Full build passed
with the exact `4e8c0e73ac` stamp; latest-head independent review is 5/5
with no open findings. All 55 latest-head checks completed with no
failures.
- Live tenant acceptance remains pending. No successful reconstruction,
export or warm-process proof is claimed by these local tests.

## Risks

- Stop retains unfinished work, so storage remains until file recovery
and explicit cleanup complete.
- Reconstruction remains held for unknown or performed external actions,
foreign owners, live or rebound environments, accepted results, changed
evidence, and unrelated later runs.
- The sweep examines at most five candidates per pass. It uses a fair
cursor and bounded transaction waits. It performs no provider call and
starts no new turn.
- Intervening verification reads only the needed JSON fields. The source
transaction still serializes recovery for the same task.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tool use and code execution.
The session does not expose an exact backend 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-10-18 01:33:11 -05:00

354 lines
26 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, environmentLeases, environments, externalAgentHolds, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { getExecutionBlocker } from "../execution-blocker.js";
import { markExecutionReconciliation, validateExecutionReconciliation } from "../execution-recovery-resolution.js";
import { prepareSandboxStopAndRetain, settleStopOnlyCleanup } from "../sandbox-stop-and-retain.js";
import { preserveCancelledNativeWorkspaces, preserveNativeFailedWorkspace } from "./native-failed-workspace-recovery.js";
import type { NativeRestartRecoveryClaim } from "./native-restart-recovery.js";
import { NativeWorkspaceFinalizationBusyError } from "./native-workspace-finalization-ownership.js";
import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../legacy-workspace-restore-recovery.js";
const externalUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("failed native workspace retention", () => {
let db: ReturnType<typeof createDb>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
beforeAll(async () => {
if (externalUrl) db = createDb(externalUrl);
else {
temporary = await startEmbeddedPostgresTestDatabase("native-failed-workspace-");
db = createDb(temporary.connectionString);
}
}, 30_000);
afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Native workspace", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Engineer", adapterType: "paperclip_runner" });
await db.insert(issues).values({ id: issueId, companyId, title: "Preserve unfinished files", status: "in_progress", assigneeAgentId: agentId });
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, nativeIssueId: issueId,
runtimeMode: "native", status: "failed", nativePhase: "terminal_failure", errorCode: "adapter_failed",
error: "runner_remote_process_identity_unavailable", resultJson: { originalDiagnostic: true }, contextSnapshot: { issueId },
}).returning();
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "terminal_failure" });
const [environment] = await db.insert(environments).values({ name: `Native recovery ${runId}`, driver: "sandbox" }).returning();
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId,
environmentId: environment.id, provider: "daytona", providerLeaseId: randomUUID(), status: "active", leasePolicy: "ephemeral",
metadata: { driver: "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: true, remoteCwd: "/work" },
}).returning();
const [bound] = await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: {
schema: "paperclip.native-workspace-sync/v3", state: "prepared", descriptorSha256: "a".repeat(64),
baselineSha256: "b".repeat(64), finalHostSha256: null, workspaceId: runId,
leaseId: lease.id, providerLeaseId: lease.providerLeaseId, remoteCwd: "/work", resourceDisposition: "destroy",
} } }).where(eq(heartbeatRuns.id, runId)).returning();
return { companyId, agentId, issueId, runId, run: bound, lease, environment };
}
const readRun = async (id: string) => (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, id)))[0]!;
const readLease = async (id: string) => (await db.select().from(environmentLeases).where(eq(environmentLeases.id, id)))[0]!;
const actions = async (issueId: string) => db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
it.each(["acknowledged", "warm", "pending", "foreign", "owned"])("pins historical cancelled work only after an exact Stop acknowledgement: %s", async kind => {
const f = await seed();
if (kind === "warm") await db.update(environmentLeases).set({ leasePolicy: "reuse_by_environment" }).where(eq(environmentLeases.id, f.lease.id));
await db.update(heartbeatRuns).set({ status: "cancelled", nativePhase: "observed", finishedAt: new Date(),
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator", nativeCancellation: {
schema: "paperclip.native-cancellation.v1", runId: f.runId,
companyId: kind === "foreign" ? randomUUID() : f.companyId, issueId: f.issueId,
scope: "run", reasonCode: "cancellation_run_only", dispatchState: kind === "pending" ? "pending" : "acknowledged",
dispatched: true, intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(),
} },
}).where(eq(heartbeatRuns.id, f.runId));
if (kind === "owned") await db.update(nativeRunFinalizations).set({ leaseOwner: "current-controller" })
.where(eq(nativeRunFinalizations.runId, f.runId));
await preserveCancelledNativeWorkspaces(db);
const current = await readRun(f.runId);
expect(current).toMatchObject({ status: "cancelled", resultJson: { nativeCancellation: { scope: "run" } } });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("in_progress");
const pinned = kind === "acknowledged" || kind === "warm";
expect(await readLease(f.lease.id)).toMatchObject(pinned
? { status: "pending_cleanup", leasePolicy: "retain_on_failure", metadata: { sandboxStopAndRetain: { runId: f.runId } } }
: { status: "active", leasePolicy: "ephemeral" });
expect(await actions(f.issueId)).toHaveLength(pinned ? 1 : 0);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
});
it("keeps another cancelled workspace recoverable when one task row is locked", async () => {
const busy = await seed(), free = await seed();
for (const f of [busy, free]) await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(),
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator", nativeCancellation: {
schema: "paperclip.native-cancellation.v1", runId: f.runId, companyId: f.companyId, issueId: f.issueId,
scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(),
} },
}).where(eq(heartbeatRuns.id, f.runId));
let release!: () => void, ready!: () => void;
const released = new Promise<void>(resolve => { release = resolve; });
const acquired = new Promise<void>(resolve => { ready = resolve; });
const holding = db.transaction(async tx => {
await tx.select().from(issues).where(eq(issues.id, busy.issueId)).for("update");
ready(); await released;
});
await acquired;
try {
for (let pass = 0; pass < 3; pass++) await preserveCancelledNativeWorkspaces(db);
expect(await readLease(busy.lease.id)).toMatchObject({ status: "active" });
expect(await readLease(free.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
} finally { release(); await holding; }
}, 15_000);
it.each(["pending_cleanup", "released"])("keeps an exact %s cancellation eligible until its file hold is recorded", async status => {
const f = await seed();
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(),
resultJson: { cancelledByActorType: "user", cancelledByUserId: "operator", nativeCancellation: {
schema: "paperclip.native-cancellation.v1", runId: f.runId, companyId: f.companyId, issueId: f.issueId,
scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(),
} },
}).where(eq(heartbeatRuns.id, f.runId));
const stopping = await prepareSandboxStopAndRetain(db, f.lease);
if (!stopping) throw new Error("Expected the exact stop-only source");
if (status === "pending_cleanup") {
await preserveCancelledNativeWorkspaces(db);
expect((await readRun(f.runId)).resultJson).not.toHaveProperty("workspaceRestoreRecovery");
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "ephemeral" });
}
await settleStopOnlyCleanup(db, stopping, { attemptId: String(stopping.metadata?.pendingCleanupAttemptId),
receipt: { providerLeaseId: f.lease.providerLeaseId, state: "stopped" } });
for (let pass = 0; pass < 3; pass++) await preserveCancelledNativeWorkspaces(db);
expect(await readLease(f.lease.id)).toMatchObject({ status: "released", leasePolicy: "retain_on_failure",
metadata: { remoteExecutionTermination: { state: "stopped" } } });
expect(await readRun(f.runId)).toMatchObject({ status: "cancelled", resultJson: {
workspaceRestoreRecovery: { leaseIds: [f.lease.id] },
} });
expect(await actions(f.issueId)).toHaveLength(1);
});
it("pins only the exact unexported source and exposes a file-only recovery card", async () => {
const f = await seed();
const [other] = await db.insert(environmentLeases).values({ companyId: f.companyId, heartbeatRunId: f.runId,
issueId: f.issueId, environmentId: f.environment.id, provider: "daytona", providerLeaseId: randomUUID(),
status: "active", metadata: f.lease.metadata,
}).returning();
await db.transaction(tx => preserveNativeFailedWorkspace(tx as unknown as typeof db, f.run));
expect(await readRun(f.runId)).toMatchObject({ status: "failed", errorCode: "adapter_failed", resultJson: {
originalDiagnostic: true, workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { leaseIds: [f.lease.id] },
} });
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure", metadata: {
sandboxStopAndRetain: { runId: f.runId, leaseId: f.lease.id, providerLeaseId: f.lease.providerLeaseId },
} });
expect(await readLease(other.id)).toMatchObject({ status: "active" });
expect(await actions(f.issueId)).toMatchObject([{ status: "resolved", outcome: "blocked", evidence: { runId: f.runId } }]);
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId,
cause: "native_continuation_requires_reconciliation", workspaceRepairRequired: true,
canRecordWorkspaceRepair: true, canRetry: false, canContinue: false,
});
await preserveNativeFailedWorkspace(db, await readRun(f.runId));
expect(await actions(f.issueId)).toHaveLength(1);
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId),
eq(activityLog.action, "issue.workspace_recovery_required")))).toHaveLength(1);
});
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot" | "muse", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string; issueId: string }; provider: { kind: string; bridgeRevision?: string }; workspace: { access: string; cwd: string | null } }) => void) {
const run = await readRun(f.runId);
const input = { schema: provider === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6",
binding: { runId: f.runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
provider: { kind: provider, ...(provider === "muse" ? { bridgeRevision: "muse-v1" } : {}) }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
change?.(input);
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "muse" ? "muse_external" : provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: input } }).where(eq(heartbeatRuns.id, f.runId)).returning();
return updated;
}
it.each(["grok_bot", "openai_dot", "muse"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
const f = await seed(), run = await bindExternalInput(f, provider);
const [hold] = provider === "muse" ? await db.insert(externalAgentHolds).values({ companyId: f.companyId, agentId: f.agentId,
provider: "muse", assignmentId: randomUUID(), bindingId: randomUUID(), bindingGeneration: 1, runId: f.runId,
workerUnknown: true, nativeEffectsUnknown: true }).returning() : [];
await preserveNativeFailedWorkspace(db, run);
expect((await readRun(f.runId)).resultJson).not.toHaveProperty("workspaceRestoreFailure");
expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" });
expect(await actions(f.issueId)).toHaveLength(0);
if (hold) expect((await db.select().from(externalAgentHolds).where(eq(externalAgentHolds.id, hold.id)))[0]).toEqual(hold);
});
it.each(["run", "company", "agent", "schema", "write_access"])("preserves file safeguards when no-workspace evidence has an invalid %s", async mismatch => {
const f = await seed();
const run = await bindExternalInput(f, "grok_bot", input => {
if (mismatch === "run") input.binding.runId = randomUUID();
if (mismatch === "company") input.binding.companyId = randomUUID();
if (mismatch === "agent") input.binding.agentId = randomUUID();
if (mismatch === "schema") input.schema = "unknown";
if (mismatch === "write_access") input.workspace.access = "read_write";
});
await preserveNativeFailedWorkspace(db, run);
expect((await readRun(f.runId)).resultJson).toHaveProperty("workspaceRestoreFailure", "restore_failed");
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
});
it.each(["run", "company", "agent", "issue", "schema", "provider", "bridge", "missing_bridge", "driver", "write_access", "cwd"] as const)("retains file safeguards for a mismatched Muse %s contract", async mismatch => {
const f = await seed();
await bindExternalInput(f, "muse", input => {
if (mismatch === "run") input.binding.runId = randomUUID();
if (mismatch === "company") input.binding.companyId = randomUUID();
if (mismatch === "agent") input.binding.agentId = randomUUID();
if (mismatch === "issue") input.binding.issueId = randomUUID();
if (mismatch === "schema") input.schema = "paperclip.native-execution-input.v6";
if (mismatch === "provider") input.provider.kind = "grok_bot";
if (mismatch === "bridge") input.provider.bridgeRevision = "unknown";
if (mismatch === "missing_bridge") delete input.provider.bridgeRevision;
if (mismatch === "write_access") input.workspace.access = "read_write";
if (mismatch === "cwd") input.workspace.cwd = "/untrusted-task-workspace";
});
if (mismatch === "driver") await db.update(heartbeatRuns).set({ driverKind: "openai_dot_mcp" }).where(eq(heartbeatRuns.id, f.runId));
await preserveNativeFailedWorkspace(db, await readRun(f.runId));
expect((await readRun(f.runId)).resultJson).toHaveProperty("workspaceRestoreFailure", "restore_failed");
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
expect(await actions(f.issueId)).toHaveLength(1);
});
it.each(["grok_bot", "openai_dot"] as const)("keeps historical %s inspection holds with an available recovery path", async provider => {
const f = await seed();
// Reproduce a hold persisted by an older cancellation path, then attach its admitted contract.
await preserveNativeFailedWorkspace(db, f.run);
const lease = await readLease(f.lease.id);
await settleStopOnlyCleanup(db, lease, { attemptId: String(lease.metadata?.pendingCleanupAttemptId),
receipt: { providerLeaseId: lease.providerLeaseId, state: "stopped" } });
await bindExternalInput(f, provider);
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
const blocker = await getExecutionBlocker(db, f.companyId, f.issueId);
expect(blocker).toMatchObject({ runId: f.runId, canRetry: false, canContinue: false,
workspaceRepairRequired: provider !== "grok_bot", canRecordWorkspaceRepair: provider !== "grok_bot" });
if (provider === "grok_bot") {
expect(blocker?.nextAction).not.toContain("files");
expect(blocker?.nextAction).toContain("Runtime");
}
expect(await actions(f.issueId)).toHaveLength(1);
});
it("records verified file repair without retrying the native run or changing its task", async () => {
const f = await seed();
await db.update(issues).set({ status: "done", assigneeAgentId: null }).where(eq(issues.id, f.issueId));
await preserveNativeFailedWorkspace(db, f.run);
const lease = await readLease(f.lease.id);
await settleStopOnlyCleanup(db, lease, { attemptId: String(lease.metadata?.pendingCleanupAttemptId),
receipt: { providerLeaseId: lease.providerLeaseId, state: "stopped" },
});
const [action] = await actions(f.issueId);
const decision = { runId: f.runId, providerStopped: true as const, actionOutcome: "mixed" as const,
outcomeEvidence: "Files verified; no external action is replayed.", workspaceRepairEvidence: "Exact retained allocation exported and every source hash checked." };
await validateExecutionReconciliation({ db, companyId: f.companyId, issueId: f.issueId,
agentId: null, sourceRunId: f.runId, decision, workspaceRepairOnly: true });
await markExecutionReconciliation(db, action!, decision, "board-operator", undefined, { workspaceRepairOnly: true });
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
expect(await db.select().from(issues).where(eq(issues.id, f.issueId))).toMatchObject([{ status: "done", assigneeAgentId: null }]);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(await actions(f.issueId)).toMatchObject([{ evidence: { continuationDelivery: "not_requested" } }]);
});
async function claimRestart(f: Awaited<ReturnType<typeof seed>>) {
const claim: NativeRestartRecoveryClaim = { kind: "reattach_remote_runner", runId: f.runId,
leaseOwner: randomUUID(), controllerGeneration: 2, providerAttempt: 3,
restartKind: "hard", recoveryRequestId: null,
remote: { providerLeaseId: f.lease.providerLeaseId!, remoteCwd: "/work" } };
await db.update(heartbeatRuns).set({ status: "running", nativePhase: "observed" }).where(eq(heartbeatRuns.id, f.runId));
await db.update(nativeRunFinalizations).set({ phase: "observed", leaseOwner: claim.leaseOwner,
leaseExpiresAt: new Date(Date.now() + 60_000), controllerGeneration: claim.controllerGeneration, attempt: claim.providerAttempt,
}).where(eq(nativeRunFinalizations.runId, f.runId));
return claim;
}
it.each([false, true])("captures a preparation failure before the terminal writer, with recovery claim: %s", async claimed => {
const f = await seed();
const restartRecovery = claimed ? await claimRestart(f) : undefined;
if (!claimed) {
await db.update(heartbeatRuns).set({ status: "running", nativePhase: "observed" }).where(eq(heartbeatRuns.id, f.runId));
await db.update(nativeRunFinalizations).set({ phase: "observed" }).where(eq(nativeRunFinalizations.runId, f.runId));
}
await preserveNativeFailedWorkspace(db, await readRun(f.runId), { allowPreterminal: true, restartRecovery });
expect(await readRun(f.runId)).toMatchObject({ status: "running", resultJson: { workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } });
if (claimed) expect(await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId)))
.toMatchObject([{ phase: "terminal_failure", leaseOwner: null, leaseExpiresAt: null, nextAttemptAt: null }]);
await db.update(heartbeatRuns).set({ status: "failed", resultJson: preserveWorkspaceRestoreRecoveryMetadataSql({ stopReason: "adapter_failed" }) })
.where(eq(heartbeatRuns.id, f.runId));
expect(await readRun(f.runId)).toMatchObject({ status: "failed", resultJson: {
stopReason: "adapter_failed", workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { leaseIds: [f.lease.id] },
} });
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ workspaceRepairRequired: true });
});
it("records terminal file retention after the executor retires its recovery claim", async () => {
const f = await seed();
const restartRecovery = await claimRestart(f);
await db.update(nativeRunFinalizations).set({ phase: "terminal_failure", leaseOwner: null, leaseExpiresAt: null })
.where(eq(nativeRunFinalizations.runId, f.runId));
await db.update(heartbeatRuns).set({ status: "failed", nativePhase: "terminal_failure" }).where(eq(heartbeatRuns.id, f.runId));
await preserveNativeFailedWorkspace(db, await readRun(f.runId), { allowPreterminal: true, restartRecovery });
expect(await readRun(f.runId)).toMatchObject({ status: "failed", nativePhase: "terminal_failure" });
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ workspaceRepairRequired: true });
});
it.each(["token", "generation", "attempt", "run", "remote", "expired", "released"])("refuses a stale %s recovery claim", async kind => {
const f = await seed();
const restartRecovery = await claimRestart(f);
if (kind === "token") restartRecovery.leaseOwner = randomUUID();
if (kind === "generation") restartRecovery.controllerGeneration += 1;
if (kind === "attempt") restartRecovery.providerAttempt += 1;
if (kind === "run") restartRecovery.runId = randomUUID();
if (kind === "remote" && restartRecovery.kind === "reattach_remote_runner") restartRecovery.remote.remoteCwd = "/other";
if (kind === "expired") await db.update(nativeRunFinalizations).set({ leaseExpiresAt: new Date(0) }).where(eq(nativeRunFinalizations.runId, f.runId));
if (kind === "released") await db.update(nativeRunFinalizations).set({ leaseOwner: null }).where(eq(nativeRunFinalizations.runId, f.runId));
await expect(preserveNativeFailedWorkspace(db, await readRun(f.runId), { allowPreterminal: true, restartRecovery }))
.rejects.toBeInstanceOf(NativeWorkspaceFinalizationBusyError);
expect(await readLease(f.lease.id)).toMatchObject({ status: "active" });
expect(await actions(f.issueId)).toHaveLength(0);
expect(await readRun(f.runId)).toMatchObject({ status: "running", nativePhase: "observed" });
});
it("refuses cleanup when another execution still owns the coordinator", async () => {
const f = await seed();
await db.update(nativeRunFinalizations).set({ phase: "observed", leaseOwner: randomUUID() })
.where(eq(nativeRunFinalizations.runId, f.runId));
await expect(preserveNativeFailedWorkspace(db, f.run, { allowPreterminal: true }))
.rejects.toBeInstanceOf(NativeWorkspaceFinalizationBusyError);
expect(await readLease(f.lease.id)).toMatchObject({ status: "active" });
expect(await actions(f.issueId)).toHaveLength(0);
});
it.each(["finalized", "retryable_failure", "result_accepted", "wrong_provider", "wrong_company"])("leaves %s outside failed-source retention", async kind => {
const f = await seed();
if (kind === "retryable_failure" || kind === "result_accepted") {
await db.update(nativeRunFinalizations).set({ phase: kind }).where(eq(nativeRunFinalizations.runId, f.runId));
} else {
const reference = f.run.runnerProfileJson!.nativeWorkspaceSync as Record<string, unknown>;
if (kind === "wrong_company") {
const foreign = await seed();
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: {
...reference, leaseId: foreign.lease.id, providerLeaseId: foreign.lease.providerLeaseId,
} } }).where(eq(heartbeatRuns.id, f.runId));
} else await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: {
...reference, ...(kind === "finalized" ? { state: "finalized", finalHostSha256: "c".repeat(64) } : { providerLeaseId: randomUUID() }),
} } }).where(eq(heartbeatRuns.id, f.runId));
}
await preserveNativeFailedWorkspace(db, await readRun(f.runId));
expect(await actions(f.issueId)).toHaveLength(0);
expect(await readLease(f.lease.id)).toMatchObject({ status: "active" });
});
it("rolls back terminal status, source retention and the recovery card together", async () => {
const f = await seed();
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.runId));
await expect(db.transaction(async tx => {
const [run] = await tx.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, f.runId)).returning();
await preserveNativeFailedWorkspace(tx as unknown as typeof db, run!);
throw new Error("rollback fixture");
})).rejects.toThrow("rollback fixture");
expect(await readRun(f.runId)).toMatchObject({ status: "running", resultJson: { originalDiagnostic: true } });
expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" });
expect(await actions(f.issueId)).toHaveLength(0);
});
});