Files
PaperClipAI/server/src/services/native-runtime/native-retired-backup-source.integration.test.ts
T
DottaandClaude Opus 5.5 2dd7a98753 fix(recovery): return a settled restore hold's warm sandbox to reuse (#20278)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Recovery tells a waiting parent or dependent when a child or blocker
stalls on the board (`issue_blocker_stalled`)
> - A legacy child that an older release bound to its parent's remote
workspace failed its save, and its save recovery was exhausted. Its
ended run still holds the shared workspace, days past the one-hour
holder cutoff
> - Workspace reservation refuses a run on such a hold and fails setup
with "The workspace still has an unfinished writer or save operation."
Every stall wake of the parent created a run that could only fail this
way
> - The stall sweep treated each failed run as an undelivered wake and
retried it every ten minutes, with no backoff and no limit. On staging
this made 72 failed runs (58 of 60 failures since a deploy), in pairs
every ten minutes
> - This pull request makes the stall sweep hold a wake while the
waiter's workspace hold refuses a run, and bounds retries of a wake
whose run fails
> - The benefit is that a held workspace no longer produces a failed run
every ten minutes, and the wake still arrives once the hold settles

## Linked Issues or Issue Description

**What happened?**
A parent task was woken by `issue_blocker_stalled` twice every ten
minutes. Each run failed in setup (`setup_failed`,
`executionRecovery.kind=bootstrap`, `providerWorkStarted=false`) with
"The workspace still has an unfinished writer or save operation." The
parent's remote workspace was held by a child's ended writer run whose
save recovery was exhausted
(`native_workspace_sync_out_retry_exhausted`, last failure
`agent_stop_unconfirmed`). The child shared the parent's workspace
because an older release created it before subtasks got their own
workspace (#19860).

**Expected behavior**
A wake that cannot start because its workspace is held must not create a
failed run every time. It should wait until the hold settles. Automatic
retries must be bounded.

**Root cause**
- `server/src/services/heartbeat/workspace-preparation.ts` rethrows
`RemoteWorkspaceWriterActiveError` as a setup failure when the ended
writer is silent past `WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS`
(documented, intended).
- `server/src/services/recovery/blocker-stall-wakeups.ts` retried every
non-delivered wake row (including `failed`) after
`REFUSED_WAKE_RETRY_MS` (10 minutes) with no backoff and no limit, and
it did not check whether the waiter could start at all.

## What Changed

- `remote-workspace-writer-hold.ts`: add
`remoteWorkspaceHoldRefusesStart`, the one rule for a hold that
reservation refuses outright (unattributed writer, or a writer silent
past the cutoff with no live owner fence).
- `workspace-preparation.ts`: use that rule instead of an inline copy,
so reservation and wake senders cannot drift.
- `blocker-stall-wakeups.ts`: before waking a waiter, read its bound
remote workspace hold. When the hold refuses a run, do not wake it;
count it as deferred and re-check at most hourly. A workspace with an
unchanged-release baseline is left to reservation, which can release it.
- `blocker-stall-wakeups.ts`: a stall wake whose run failed is retried
after 10, then 20 minutes, and not after the third failed run. Refusals
(wake-on-demand off, agent paused) keep the 10-minute retry. A new stall
(new recovery action) wakes again.
- `doc/execution-semantics.md`: document both rules.

## Verification

- Red before the fix
(`server/src/__tests__/issue-blocker-stall-wakeups.test.ts`): 3 new
tests failed. The held-workspace test got a delivered wake; the backoff
test got a third wake 11 minutes after the second failure.
- Green after the fix: `npx vitest run
src/__tests__/issue-blocker-stall-wakeups.test.ts` passes 20/20.
- The new tests use the staging shape: a legacy `shared_workspace`
remote workspace owned by the parent, a child bound to it, the child's
failed native writer run ended four days ago with
`native_workspace_sync_out_retry_exhausted`, a dirty authority, and the
child's board-owned exhausted recovery action. They also prove that a
writer inside the cutoff still gets its wake (the run parks in a
workspace wait), and that the wake is delivered once the hold settles.
- Related suites pass: `heartbeat-remote-workspace-busy.test.ts`,
`heartbeat-dependency-scheduling.test.ts`,
`native-cancelled-workspace-preservation.test.ts`,
`src/modules/run-dispatch`.
- `npx tsc --noEmit -p server` passes.
- `pnpm verify` exits 2 because `doc/execution-semantics.md` is a global
CI input; required CI supplies the broad coverage.

## Risks

- Low. No schema or persisted-shape change. Saved state from the
previous release (existing wake rows, existing workspace authority) is
read as-is: earlier `failed` stall wake rows count toward the new
three-run limit, which stops the current loop at once after deploy.
- A parent whose workspace stays held is not woken for the stall until
the hold settles. The stall is still listed on its page and the child's
recovery action stays with the board.
- Not fixed here: the exhausted save hold itself is never settled
automatically (`agent_stop_unconfirmed` exhaustion in workspace
recovery), and legacy children that still share a parent's workspace are
not moved. Both touch workspace recovery code that is under concurrent
change and are left for a follow-up.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, browser read-only inspection of
staging).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:28:04 -06:00

170 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
import { recoveryPostgresSupport, startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { createReusableLeaseHandoff } from "../environment-lease-handoff.js";
import { retiredSourceAuthorizesNativeBackupRestore } from "./native-retired-backup-source.js";
const describeDb = recoveryPostgresSupport.supported ? describe : describe.skip;
describeDb("retired native backup source authority", () => {
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startRecoveryPostgres("retired-backup-source");
db = createDb(database.connectionString);
}, 120_000);
afterAll(async () => { await database?.cleanup(); });
const manifestSha256 = `sha256:${"b".repeat(64)}`;
const completedAt = new Date("2026-11-05T05:02:50.000Z");
async function fixture(options: { termination?: "destroyed" | "stopped" | null; status?: string; stampSha?: string;
providerEventAt?: Date; secondLease?: boolean; handoffs?: number;
handoffFault?: "missing_receipt" | "changed_receipt" | "active_predecessor" | "missing_predecessor";
restoreHold?: "settled" | "no_marker" | "foreign_marker" | "reusable_policy" | "no_stop_receipt" | "dirty_writer" } = {}) {
const companyId = randomUUID(), agentId = randomUUID(), sessionId = randomUUID(), runnerInstanceId = randomUUID();
const sourceRunId = randomUUID(), currentRunId = randomUUID(), leaseId = randomUUID(), providerLeaseId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Retired source", issuePrefix: `R${companyId.slice(0, 5)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Codie", adapterType: "paperclip_runner" });
for (const id of [sourceRunId, currentRunId]) await db.insert(heartbeatRuns).values({ id, companyId, agentId,
status: id === sourceRunId ? "succeeded" : "running", runtimeMode: "native", nativeSessionId: sessionId,
invocationSource: "assignment", triggerDetail: "system" });
const lease = (id: string) => ({ id, companyId, heartbeatRunId: sourceRunId, provider: "daytona", providerLeaseId,
status: options.status ?? "expired", cleanupStatus: "success", releasedAt: new Date(),
metadata: {
nativeHarnessBackup: { schema: "paperclip.native-harness-backup-stamp.v2", normalizedSessionId: sessionId, runnerInstanceId,
sessionScopeSha256: "a".repeat(64), sourceProviderLeaseId: providerLeaseId, authorizedProviderLeaseId: providerLeaseId,
manifestSha256: options.stampSha ?? manifestSha256, completedAt: completedAt.toISOString() },
...(options.termination === null ? {} : { remoteExecutionTermination: { schema: "paperclip.remote-termination.v1", companyId,
runId: sourceRunId, leaseId: id, provider: "daytona", providerLeaseId, state: options.termination ?? "destroyed",
confirmedAt: new Date().toISOString() } }),
} });
const sourceLease: ReturnType<typeof lease> & Partial<typeof environmentLeases.$inferInsert> = lease(leaseId);
if (options.restoreHold) {
// The exact row an older release left after a restore hold settled:
// released, retain_on_failure, stop-and-retain receipt, hold marker.
const hold = options.restoreHold, pluginId = randomUUID();
const termination = sourceLease.metadata.remoteExecutionTermination!;
Object.assign(sourceLease, { status: "released", leasePolicy: hold === "reusable_policy" ? "reuse_by_environment" : "retain_on_failure",
acquiredAt: new Date(Date.now() - 60_000), releasedAt: new Date(Date.now() + 1_000) });
sourceLease.metadata = { ...sourceLease.metadata, pluginId,
...(hold === "no_marker" ? {} : { workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1",
runId: hold === "foreign_marker" ? randomUUID() : sourceRunId } }),
...(hold === "no_stop_receipt" ? {} : { sandboxStopAndRetainReceipt: { schema: "paperclip.sandbox-stop-and-retain-receipt.v1",
requestId: randomUUID(), companyId, runId: sourceRunId, leaseId, provider: "daytona", providerLeaseId, pluginId,
method: "environmentStopLease", confirmedAt: termination.confirmedAt } }) };
if (hold === "dirty_writer") await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceSync: {
schema: "paperclip.native-workspace-sync/v3", state: "prepared", leaseId, providerLeaseId, workspaceId: randomUUID(),
remoteCwd: "/work", resourceDisposition: "keep_running", descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64),
finalHostSha256: null } } }).where(eq(heartbeatRuns.id, sourceRunId));
}
if (options.handoffs) {
const environmentId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID();
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
await db.insert(issues).values({ id: issueId, companyId, title: "Retained session" });
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId,
mode: "shared_workspace", strategyType: "project_primary", name: "Retained workspace" });
const history: Array<typeof environmentLeases.$inferInsert> = [];
for (let index = 0; index <= options.handoffs; index++) {
const ownerRunId = index === options.handoffs ? sourceRunId : randomUUID();
if (ownerRunId !== sourceRunId) await db.insert(heartbeatRuns).values({ id: ownerRunId, companyId, agentId,
status: "succeeded", runtimeMode: "native", nativeSessionId: sessionId,
invocationSource: "assignment", triggerDetail: "system" });
const acquiredAt = new Date(completedAt.getTime() - (options.handoffs - index + 1) * 60_000);
const row = { ...sourceLease, id: index === options.handoffs ? leaseId : randomUUID(),
heartbeatRunId: ownerRunId, environmentId, issueId, executionWorkspaceId: workspaceId, acquiredAt,
leasePolicy: "reuse_by_environment", metadata: { ...sourceLease.metadata, agentId },
// Only the tip carries a settled restore hold; its predecessors retired by handoff.
...(options.restoreHold ? index === options.handoffs ? { leasePolicy: sourceLease.leasePolicy } : { status: "expired" } : {}) };
if (index < options.handoffs) {
const { nativeHarnessBackup: _backup, remoteExecutionTermination: _termination, ...metadata } = row.metadata;
history.push({ ...row, metadata });
} else history.push(row);
}
for (let index = 1; index < history.length; index++) {
const previous = history[index - 1]!, current = history[index]!;
const handoff = createReusableLeaseHandoff(previous as typeof environmentLeases.$inferSelect,
current as typeof environmentLeases.$inferSelect);
previous.releasedAt = current.acquiredAt;
previous.metadata = { ...previous.metadata, reusableLeaseSuccessor: handoff };
current.metadata = { ...current.metadata, reusableLeasePredecessor: handoff };
}
if (options.handoffFault === "missing_receipt") delete history[0]!.metadata!.reusableLeaseSuccessor;
if (options.handoffFault === "changed_receipt") history[0]!.metadata!.reusableLeaseSuccessor = {
...(history[0]!.metadata!.reusableLeaseSuccessor as Record<string, unknown>), successorRunId: randomUUID() };
if (options.handoffFault === "active_predecessor") history[0]!.status = "active";
if (options.handoffFault === "missing_predecessor") history.shift();
await db.insert(environmentLeases).values(history);
} else await db.insert(environmentLeases).values(sourceLease);
if (options.secondLease) await db.insert(environmentLeases).values(lease(randomUUID()));
// The backup turn's own provider events precede the checkpoint.
await db.insert(heartbeatRunEvents).values({ companyId, runId: sourceRunId, agentId, seq: 1, eventType: "turn.started",
createdAt: new Date(completedAt.getTime() - 60_000) });
if (options.providerEventAt) await db.insert(heartbeatRunEvents).values({ companyId, runId: currentRunId === sourceRunId
? sourceRunId : (await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", runtimeMode: "native",
nativeSessionId: sessionId, invocationSource: "automation", triggerDetail: "system" }).returning())[0]!.id,
agentId, seq: 1, eventType: "session.resumed", createdAt: options.providerEventAt });
return retiredSourceAuthorizesNativeBackupRestore(db, { companyId, currentRunId, normalizedSessionId: sessionId,
manifest: { sourceProviderLeaseId: providerLeaseId, runnerInstanceId, normalizedSessionId: sessionId }, manifestSha256 });
}
it("authorizes a destroyed source lease whose stamp names the backup", async () => {
await expect(fixture()).resolves.toBe(true);
});
it.each([1, 3])("authorizes a destroyed allocation after %i verified warm lease handoffs", async (handoffs) => {
await expect(fixture({ handoffs })).resolves.toBe(true);
});
it.each(["missing_receipt", "changed_receipt", "active_predecessor", "missing_predecessor"] as const)(
"fails closed for a warm lease chain with %s", async (handoffFault) => {
await expect(fixture({ handoffs: 2, handoffFault })).resolves.toBe(false);
});
it.each([
["an unrelated owner", { secondLease: true }],
["a stopped source", { termination: "stopped" as const }],
["another checkpoint", { stampSha: `sha256:${"c".repeat(64)}` }],
["later provider work", { providerEventAt: new Date(completedAt.getTime() + 60_000) }],
])("keeps the backup restore held after warm handoffs with %s", async (_label, options) => {
await expect(fixture({ handoffs: 2, ...options })).resolves.toBe(false);
});
it.each([
["the provider only stopped the source", { termination: "stopped" as const }],
["no termination receipt exists", { termination: null }],
["the source lease is still reusable", { status: "released" }],
["the stamp names another checkpoint", { stampSha: `sha256:${"c".repeat(64)}` }],
["provider work followed the checkpoint", { providerEventAt: new Date(completedAt.getTime() + 60_000) }],
["one allocation maps to two host leases", { secondLease: true }],
])("fails closed when %s", async (_label, options) => {
await expect(fixture(options)).resolves.toBe(false);
});
// Regression, old-saved/new-resumed: an older release stopped a warm sandbox
// under a restore hold, moved it to retain_on_failure, and never returned it
// to reuse. Nothing can select it again, so its backup is the newest state.
it.each([0, 2])("authorizes the backup of a settled restore hold after %i warm handoffs", async (handoffs) => {
await expect(fixture({ termination: "stopped", restoreHold: "settled", handoffs })).resolves.toBe(true);
});
it.each([
["the hold marker is missing", { restoreHold: "no_marker" as const }],
["the hold marker names another run", { restoreHold: "foreign_marker" as const }],
["the stopped lease is still reusable", { restoreHold: "reusable_policy" as const }],
["no stop-and-retain receipt exists", { restoreHold: "no_stop_receipt" as const }],
["an unsaved writer is still bound to it", { restoreHold: "dirty_writer" as const }],
["the stamp names another checkpoint", { restoreHold: "settled" as const, stampSha: `sha256:${"c".repeat(64)}` }],
["provider work followed the checkpoint", { restoreHold: "settled" as const, providerEventAt: new Date(completedAt.getTime() + 60_000) }],
["one allocation maps to two host leases", { restoreHold: "settled" as const, secondLease: true }],
])("keeps a restore-hold backup held when %s", async (_label, options) => {
await expect(fixture({ termination: "stopped", ...options })).resolves.toBe(false);
});
it("ignores provider work that finished before the checkpoint", async () => {
await expect(fixture({ providerEventAt: new Date(completedAt.getTime() - 1_000) })).resolves.toBe(true);
});
});