mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - When a remote workspace save fails, recovery actions hold the task until the run's files are saved > - #20641 lets a resolved no-replay hold settle when a sibling action of the same run saved the files > - Review found that the settlement did not release the task's Blocked status, and that a settled hold with a historical source stayed selectable > - A new test with the waiting user comment also showed that the worker skipped the second hold of the same run in every pass, so it never settled > - #20641 was merged before these fixes were pushed > - This pull request completes #20641 with those fixes > - The benefit is that the task leaves Blocked and continues without a person ## Linked Issues or Issue Description Refs #20641, Refs #20569 **What happened?** #20641 merged at its first head. The review follow-ups for it were pushed later and are not on master: - A settled no-replay hold that owned the task's Blocked status (`nativeFailureBlock`) left the task `blocked`. - A hold settled by a sibling stayed selectable through `workspaceRecoverySourceEligible` when it named a `historicalWorkspaceSource`. - With saved user input waiting, the export hold stayed selectable and came first in each worker pass. `reconcileRetainedWorkspaces` skipped the other hold of the same run, so the no-replay hold never settled. **Expected behavior** After the save, the no-replay hold settles in the same or next pass. The task returns to `todo` when that hold owns its Blocked status. The settled hold is never selected again. **Steps to reproduce** 1. Use the staging-shape test in `workspace-recovery-capture-policy.test.ts`, with a waiting "try again" comment. 2. Run the worker until nothing is due. 3. On master, the worker never goes idle and the no-replay hold stays `resolved`/`blocked`. ## What Changed - `workspace-recovery-fallback.ts`: new shared `holdOwnsBlockedStatus`, used by `continueFromLastSavedWorkspace` and `settleSiblingRecoveredHold`. A settled no-replay hold that owns the Blocked status returns the task to `todo`. A newer decision's Blocked status is kept. An export hold's status stays with its own result finalization. - `workspace-recovery-source.ts`: `workspaceRecoverySourceEligible` excludes a resolved hold that carries `siblingWorkspaceRecovery`. - `workspace-recovery.ts`: `reconcileRetainedWorkspaces` runs several holds of one run one after another in that run's job, instead of skipping the later ones. - `workspace-recovery-capture-policy.test.ts`: the staging test now has the waiting comment, and a variant with `nativeFailureBlock` and `historicalWorkspaceSource`. It checks `todo`, no re-selection, and no repeat handoff in a later pass. - `doc/execution-semantics.md`: documents these rules. ## Verification - Red, with each rule disabled: the status stayed `blocked`; the settled hold was selected again; the worker never went idle (starvation). - Green: `npx vitest run --root server` on 14 workspace recovery suites (including `workspace-recovery-capture-policy`, `-fallback`, `-admission`, `-source`, `execution-blocker-read`, `preserved-native-response-wait.integration`): all passed. - `npx tsc --noEmit -p server/tsconfig.json`: clean. ## Risks - Several holds of one run now run in sequence in one job. Concurrency per run is unchanged (one job per run). - A no-replay hold releases the Blocked status only when it still owns it. Otherwise the status is kept. - Previous-release story: no new persisted state. Holds saved by earlier releases are handled on the first worker pass after deploy. > 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 (Anthropic), model ID `claude-opus-5-5`, used through Claude Code with tool use and code execution. ## 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>
528 lines
40 KiB
TypeScript
528 lines
40 KiB
TypeScript
import { REMOTE_WORKSPACE_CAPTURE_POLICY_VERSION } from "./remote-workspace-snapshot-archive.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { execFile } from "node:child_process";
|
|
import { cp, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { agentWakeupRequests, assets, environmentLeases, executionWorkspaces, heartbeatRuns, issueAttachments, issueComments,
|
|
issueRecoveryActions, issues, nativeRunFinalizations, workspaceOperations } from "@paperclipai/db";
|
|
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
|
import { readRemoteWorkspaceAuthority, type EnvironmentLease } from "@paperclipai/shared";
|
|
import { getStorageService } from "../storage/index.js";
|
|
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
|
|
import { getExecutionBlocker } from "./execution-blocker.js";
|
|
import { deliverReconciledExecutions } from "./execution-recovery-resolution.js";
|
|
import { prepareRemoteWorkspace, reserveRemoteWorkspace, saveRemoteWorkspace } from "./remote-workspaces.js";
|
|
import { readSystemRecovery } from "./system-recovery-state.js";
|
|
import { reconcileRetainedWorkspaces, recoverRetainedWorkspace, retainedWorkspaceCandidates } from "./workspace-recovery.js";
|
|
import { nextWorkspaceRecoveryAt } from "./system-recovery.js";
|
|
import { createOwnershipReportState, runOwnershipReconciliationPass } from "./recovery/ownership-invariant-report.js";
|
|
import { RETAINED_SOURCE_FALLBACK_OUTCOME, RETAINED_SOURCE_QUARANTINE_PHASE } from "./workspace-recovery-fallback.js";
|
|
import { hasVerifiedWorkspaceRepair, readWorkspaceRecovery } from "./workspace-recovery-receipt.js";
|
|
import { acceptedRemoteExport, action, connectionString, db, run, useWorkspaceRecoveryFixture } from "./__tests__/workspace-recovery-fixture.js";
|
|
|
|
const targets = vi.hoisted(() => new Map<string, AdapterSandboxExecutionTarget>());
|
|
vi.mock("./environment-execution-target.js", async importOriginal => {
|
|
const actual = await importOriginal<typeof import("./environment-execution-target.js")>();
|
|
return { ...actual, resolveEnvironmentExecutionTarget: async (input: Parameters<typeof actual.resolveEnvironmentExecutionTarget>[0]) =>
|
|
(input.leaseId ? targets.get(input.leaseId) : undefined) ?? actual.resolveEnvironmentExecutionTarget(input) };
|
|
});
|
|
|
|
// Recovery text never asks a person to inspect or fix a sandbox.
|
|
const adminText = /sandbox|environment|administrator|board member|owner must|inspect the run/i;
|
|
const workspace = async (id: string) => (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id)))[0]!;
|
|
|
|
describe("retained source fallback", () => {
|
|
useWorkspaceRecoveryFixture(targets);
|
|
// Remote workspace storage needs external Postgres and a stable key, for
|
|
// the checkpoint save and the later restore alike.
|
|
beforeEach(() => {
|
|
vi.stubEnv("DATABASE_URL", connectionString);
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "63".repeat(32));
|
|
});
|
|
afterEach(() => { vi.unstubAllEnvs(); });
|
|
|
|
/**
|
|
* A child task's run saved a checkpoint (the last committed
|
|
* snapshot), then wrote more files and failed to save them. Its reusable
|
|
* sandbox lease history crosses into the parent task, so the recorded source
|
|
* is refused with allocation_ownership_ambiguous on every attempt.
|
|
*/
|
|
async function unverifiableRetainedSource(saved: "old_release" | "fresh" = "old_release") {
|
|
const f = await acceptedRemoteExport("retained");
|
|
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
const checkpoint = await saveRemoteWorkspace({ db, workspace: await workspace(f.workspaceId), runId: f.runId,
|
|
lease: lease as EnvironmentLease, target: targets.get(f.lease.id)!, assertOwned: async () => {}, boundary: "checkpoint" });
|
|
// The published root a later preparation would otherwise reuse.
|
|
await db.update(executionWorkspaces).set({ providerRef: f.remote }).where(eq(executionWorkspaces.id, f.workspaceId));
|
|
await writeFile(path.join(f.remote, "unverified.txt"), "unsaved work\n");
|
|
await writeFile(path.join(f.remote, ".env"), "TOKEN=secret\n");
|
|
await writeFile(path.join(f.remote, "id_rsa"), "private key\n");
|
|
// The run's terminal failure blocked the task (older releases kept no receipt).
|
|
const finished = (await run(f.runId)).finishedAt!;
|
|
await db.update(issues).set({ blockedTransitionAt: new Date(finished.getTime() + 100) }).where(eq(issues.id, f.issueId));
|
|
await db.update(issueRecoveryActions).set({ createdAt: finished }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Parent task" }).returning();
|
|
await db.update(issues).set({ parentId: parent.id }).where(eq(issues.id, f.issueId));
|
|
await db.insert(environmentLeases).values({ companyId: f.companyId, environmentId: lease.environmentId, issueId: parent.id,
|
|
provider: "daytona", providerLeaseId: f.providerLeaseId, status: "expired", leasePolicy: "reuse_by_environment",
|
|
cleanupStatus: "success", releasedAt: new Date(), metadata: { remoteCwd: path.dirname(f.remote) } });
|
|
await db.update(nativeRunFinalizations).set({ failureCode: "native_workspace_sync_out_retry_exhausted" })
|
|
.where(eq(nativeRunFinalizations.runId, f.runId));
|
|
const oldMessage = "Automatic recovery of saved work stopped after 1 attempts (last failure: allocation_ownership_ambiguous). "
|
|
+ "Retry recovery to export and merge from the retained sandbox again; no provider turn is submitted. "
|
|
+ "If Retry keeps failing, inspect the run and its environment.";
|
|
if (saved === "old_release") {
|
|
// The exact shape saved by the Nov 3-6 releases.
|
|
const current = readSystemRecovery((await action(f.actionId)).evidence)!;
|
|
await db.update(issueRecoveryActions).set({ ownerType: "board", nextAction: oldMessage, evidence: { ...(await action(f.actionId)).evidence,
|
|
systemRecovery: { ...current, revision: current.revision + 5, phase: "verify_execution", state: "exhausted", attempts: 1,
|
|
attemptId: randomUUID(), firstFailureAt: Date.now() - 60_000, deadlineAt: Date.now() + 60_000, nextAttemptAt: null,
|
|
failure: { code: "allocation_ownership_ambiguous", retryClass: "service_required", evidenceFingerprint: "a".repeat(64) } },
|
|
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: f.runId, status: "blocked", stage: "verifying_stop",
|
|
attempt: 1, attemptLimit: 6, retryable: true, requestIds: [], message: oldMessage } } })
|
|
.where(eq(issueRecoveryActions.id, f.actionId));
|
|
}
|
|
return { ...f, snapshotId: checkpoint.snapshotId, parentIssueId: parent.id };
|
|
}
|
|
|
|
async function continuationBlock(f: { companyId: string; issueId: string; runId: string }) {
|
|
const source = await run(f.runId);
|
|
const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
|
authorType: "user", authorUserId: "operator", body: "Please continue" }).returning();
|
|
const reasons: string[] = [];
|
|
await admitExplicitNativeContinuation({ db, companyId: f.companyId, issueId: f.issueId, agentId: source.agentId,
|
|
actorType: "user", actorId: "operator", reason: "issue_commented", commentId: comment.id, successorRunId: randomUUID(),
|
|
dryRun: true, onBlocked: reason => reasons.push(reason) });
|
|
return reasons;
|
|
}
|
|
|
|
it.each(["old_release", "fresh"] as const)("continues a cross-task retained hold (%s) from the last save, quarantined and without reusing its root", async saved => {
|
|
const f = await unverifiableRetainedSource(saved);
|
|
expect((await getExecutionBlocker(db, f.companyId, f.issueId))?.workspaceRepairRequired).toBe(true);
|
|
if (saved === "fresh") {
|
|
// A current release records which Blocked status version this hold owns.
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const source = await run(f.runId);
|
|
await db.update(issueRecoveryActions).set({ evidence: { ...(await action(f.actionId)).evidence, workspaceFinalizationBlock: {
|
|
schema: "paperclip.native-workspace-finalization-block.v1", companyId: f.companyId, issueId: f.issueId, runId: f.runId,
|
|
agentId: source.agentId, resultId: f.resultId, resultSha256: "result", contractId: source.completionContractId,
|
|
contractSha256: "contract", identityContextId: null, responsibleUserId: null,
|
|
statusVersion: Number(task.statusVersion), priorDecisionId: task.lastStatusDecisionId } } })
|
|
.where(eq(issueRecoveryActions.id, f.actionId));
|
|
}
|
|
|
|
await recoverRetainedWorkspace(f.input);
|
|
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ status: "resolved", outcome: "restored", ownerType: "system",
|
|
resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME, returnOwnerAgentId: (await run(f.runId)).agentId });
|
|
expect(readSystemRecovery(settled.evidence)?.failure?.code).toBe("allocation_ownership_ambiguous");
|
|
const decision = settled.evidence.executionReconciliation as Record<string, unknown>;
|
|
expect(decision).toMatchObject({ runId: f.runId, providerStopped: true, actionOutcome: "completed",
|
|
retainedSourceFallback: { outcome: RETAINED_SOURCE_FALLBACK_OUTCOME, snapshotId: f.snapshotId,
|
|
quarantine: { state: "captured" } } });
|
|
expect(settled.evidence.continuationDelivery).toBe("pending");
|
|
for (const text of [settled.nextAction, readWorkspaceRecovery(settled.evidence)?.message, decision.outcomeEvidence])
|
|
expect(String(text)).not.toMatch(adminText);
|
|
expect(String(decision.outcomeEvidence)).toContain(f.snapshotId);
|
|
|
|
// The unverified files are attached to the task, never applied.
|
|
const quarantine = (decision.retainedSourceFallback as { quarantine: { attachmentId: string; sha256: string } }).quarantine;
|
|
const [attachment] = await db.select({ asset: assets }).from(issueAttachments)
|
|
.innerJoin(assets, eq(assets.id, issueAttachments.assetId)).where(eq(issueAttachments.id, quarantine.attachmentId));
|
|
expect(attachment.asset).toMatchObject({ companyId: f.companyId, sha256: quarantine.sha256, contentType: "application/x-tar" });
|
|
const scratch = await mkdtemp(path.join(os.tmpdir(), "quarantine-check-"));
|
|
try {
|
|
const object = await getStorageService().getObject(f.companyId, attachment.asset.objectKey);
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of object.stream) chunks.push(chunk as Buffer);
|
|
await writeFile(path.join(scratch, "files.tar"), Buffer.concat(chunks));
|
|
const listing = await new Promise<string>((resolve, reject) => execFile("tar", ["-tf", path.join(scratch, "files.tar")],
|
|
(error, stdout) => error ? reject(error) : resolve(stdout)));
|
|
expect(listing).toContain("unverified.txt");
|
|
// The workspace file policy applies: secrets are never attached.
|
|
expect(listing).not.toMatch(/\.env|id_rsa/);
|
|
} finally { await rm(scratch, { recursive: true, force: true }); }
|
|
|
|
// The unverified epoch is abandoned: no writer, not dirty, the last save kept, no location.
|
|
const abandoned = await workspace(f.workspaceId);
|
|
const before = (await run(f.runId)).runnerProfileJson!.remoteWorkspace as { epoch: number };
|
|
expect(readRemoteWorkspaceAuthority(abandoned.metadata)).toMatchObject({ epoch: before.epoch + 1, writerRunId: null,
|
|
dirty: false, location: null, snapshotId: f.snapshotId });
|
|
expect(abandoned.providerRef).toBeNull();
|
|
|
|
// Every gate is settled: the task hold, the repair flag and the saved-work wait.
|
|
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("todo");
|
|
expect(String(decision.workspaceRepairEvidence).length).toBeGreaterThanOrEqual(20);
|
|
expect(await continuationBlock(f)).not.toEqual(expect.arrayContaining(["saved_work_recovery_failed"]));
|
|
expect(await continuationBlock(f)).not.toEqual(expect.arrayContaining(["workspace_repair_required"]));
|
|
|
|
// One continuation is delivered through the normal reconciled path.
|
|
const wake = vi.fn(async () => ({ id: randomUUID() }));
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
const calls = wake.mock.calls.filter(call => (call as unknown[])[0] === settled.returnOwnerAgentId);
|
|
expect(calls).toHaveLength(1);
|
|
expect((calls[0] as unknown[])[1]).toMatchObject({ reason: "issue_recovery_action_restored",
|
|
contextSnapshot: { issueId: f.issueId, previousRunId: f.runId, forceFreshSession: true, source: "execution.reconciled" } });
|
|
|
|
// The next writer restores the last save into a new root; the old root is untouched.
|
|
const source = await run(f.runId);
|
|
const nextRunId = randomUUID(), nextLeaseId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: nextRunId, companyId: f.companyId, agentId: source.agentId, issueId: f.issueId,
|
|
nativeIssueId: f.issueId, runtimeMode: "native", status: "running", contextSnapshot: { issueId: f.issueId } });
|
|
const storage = { ...getStorageService(), provider: "s3" as const };
|
|
const reserved = await reserveRemoteWorkspace({ db, storage, companyId: f.companyId, issueId: f.issueId, runId: nextRunId,
|
|
agentId: source.agentId, environmentId: f.lease.environmentId!, projectId: null, projectWorkspaceId: null,
|
|
existingWorkspaceId: f.workspaceId, source: { kind: "empty" } });
|
|
const [nextLease] = await db.insert(environmentLeases).values({ id: nextLeaseId, companyId: f.companyId, issueId: f.issueId,
|
|
heartbeatRunId: nextRunId, environmentId: f.lease.environmentId, provider: "daytona", providerLeaseId: f.providerLeaseId,
|
|
status: "active", leasePolicy: "reuse_by_environment" }).returning();
|
|
const oldRoot = await realpath(f.remote), base = path.dirname(path.dirname(oldRoot));
|
|
const sandbox = targets.get(f.lease.id)!;
|
|
// The same sandbox: transfers into it are plain copies.
|
|
const runner = { ...sandbox.runner!, syncIn: async (operations: Parameters<NonNullable<typeof sandbox.runner>["syncOut"] & {}>[0]) => {
|
|
for (const operation of operations) for (const file of operation.files)
|
|
await cp(file.sourcePath, file.targetPath, { recursive: true, dereference: false });
|
|
return { filesTransferred: 1, bytesTransferred: 0, operations: [] };
|
|
} };
|
|
const prepared = await prepareRemoteWorkspace({ db, storage, workspace: reserved, runId: nextRunId,
|
|
lease: nextLease as EnvironmentLease, target: { ...sandbox, runner, leaseId: nextLeaseId, remoteCwd: base } });
|
|
expect(prepared.remoteRoot).not.toBe(oldRoot);
|
|
expect(path.dirname(prepared.remoteRoot)).toBe(path.dirname(oldRoot));
|
|
expect(await readFile(path.join(prepared.remoteRoot, "saved.txt"), "utf8")).toBe("finished work\n");
|
|
await expect(readFile(path.join(prepared.remoteRoot, "unverified.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await readFile(path.join(f.remote, "unverified.txt"), "utf8")).toBe("unsaved work\n");
|
|
}, 60_000);
|
|
|
|
it("keeps a newer Blocked decision and starts no continuation, while settling the file hold", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
await db.update(issues).set({ blockedTransitionAt: new Date(Date.now() + 120_000) }).where(eq(issues.id, f.issueId));
|
|
await recoverRetainedWorkspace(f.input);
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
expect(settled.evidence.continuationDelivery).toBe("not_requested");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("blocked");
|
|
const wake = vi.fn(async () => ({ id: randomUUID() }));
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
expect(wake.mock.calls.filter(call => (call as unknown[])[0] === settled.returnOwnerAgentId)).toHaveLength(0);
|
|
}, 60_000);
|
|
|
|
it("still continues when the retained root cannot be read, recording why", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
await rm(f.remote, { recursive: true, force: true });
|
|
await recoverRetainedWorkspace(f.input);
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
const fallback = (settled.evidence.executionReconciliation as { retainedSourceFallback: { quarantine: { state: string; reason: string } } })
|
|
.retainedSourceFallback;
|
|
expect(fallback.quarantine.state).toBe("unreadable");
|
|
expect(fallback.quarantine.reason).toMatch(/^[a-z][a-z0-9_]*$/);
|
|
expect(String(settled.nextAction)).not.toMatch(adminText);
|
|
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toMatchObject({ writerRunId: null, dirty: false,
|
|
location: null, snapshotId: f.snapshotId });
|
|
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
|
expect(await db.select().from(issueAttachments).where(eq(issueAttachments.issueId, f.issueId))).toHaveLength(0);
|
|
}, 60_000);
|
|
|
|
it("is idempotent: a second pass changes nothing and delivers no second continuation", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const agentId = (await run(f.runId)).agentId;
|
|
await recoverRetainedWorkspace(f.input);
|
|
const first = await action(f.actionId), firstWorkspace = await workspace(f.workspaceId);
|
|
await recoverRetainedWorkspace(f.input);
|
|
expect(await action(f.actionId)).toEqual(first);
|
|
expect(await workspace(f.workspaceId)).toEqual(firstWorkspace);
|
|
expect(await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.heartbeatRunId, f.runId),
|
|
eq(workspaceOperations.phase, RETAINED_SOURCE_QUARANTINE_PHASE)))).toHaveLength(1);
|
|
expect(await db.select().from(issueAttachments).where(eq(issueAttachments.issueId, f.issueId))).toHaveLength(1);
|
|
const wake = vi.fn(async () => ({ id: randomUUID() }));
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
expect(wake.mock.calls.filter(call => (call as unknown[])[0] === agentId)).toHaveLength(1);
|
|
}, 60_000);
|
|
|
|
it("leaves saved user input to continue the task instead of starting a second continuation", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: (await run(f.runId)).agentId, source: "on_demand",
|
|
reason: "issue_commented", status: "deferred_issue_execution", requestedByActorType: "user", requestedByActorId: "operator",
|
|
payload: { issueId: f.issueId } });
|
|
await recoverRetainedWorkspace(f.input);
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
expect(settled.evidence.continuationDelivery).toBe("not_requested");
|
|
}, 60_000);
|
|
|
|
it.each(["live_writer", "pending_side_effect", "uncertain_external_action", "sandbox_in_use"] as const)(
|
|
"keeps the hold and starts no executor with a %s", async fault => {
|
|
const f = await unverifiableRetainedSource();
|
|
const source = await run(f.runId);
|
|
if (fault === "live_writer") await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: source.agentId,
|
|
issueId: f.issueId, runtimeMode: "native", status: "running", startedAt: new Date(), contextSnapshot: { issueId: f.issueId } });
|
|
if (fault === "sandbox_in_use") {
|
|
// The source allocation has no stop receipt, and another task holds the sandbox.
|
|
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
const metadata = { ...lease.metadata }; delete metadata.remoteExecutionTermination;
|
|
await db.update(environmentLeases).set({ metadata }).where(eq(environmentLeases.id, f.lease.id));
|
|
await db.update(environmentLeases).set({ status: "active", releasedAt: null }).where(and(
|
|
eq(environmentLeases.issueId, f.parentIssueId), eq(environmentLeases.providerLeaseId, f.providerLeaseId)));
|
|
}
|
|
if (fault === "pending_side_effect" || fault === "uncertain_external_action") await db.update(heartbeatRuns).set({ resultJson: { ...source.resultJson, apiToolReceipts: {
|
|
effect: fault === "pending_side_effect" ? { state: "pending", operationId: "POST /api/x" }
|
|
: { state: "completed", operationId: "POST /api/x", result: { ok: false, status: 502 } } } } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata);
|
|
await recoverRetainedWorkspace(f.input);
|
|
const held = await action(f.actionId);
|
|
expect(held.status).toBe("active");
|
|
expect(held.evidence.executionReconciliation).toBeUndefined();
|
|
expect(held.evidence.continuationDelivery).toBeUndefined();
|
|
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toEqual(authority);
|
|
expect(await db.select().from(issueAttachments).where(eq(issueAttachments.issueId, f.issueId))).toHaveLength(0);
|
|
const wake = vi.fn(async () => ({ id: randomUUID() }));
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
expect(wake.mock.calls.filter(call => (call as unknown[])[0] === source.agentId)).toHaveLength(0);
|
|
if (fault === "sandbox_in_use") expect(f.runtime.retryPendingSandboxTeardown).not.toHaveBeenCalled();
|
|
// The deferral is recorded: transient waits are checked again later, a
|
|
// permanent refusal waits for new input or a new generation.
|
|
const check = held.evidence.retainedSourceFallbackCheck as { reason: string; nextCheckAt: number | null };
|
|
expect(check.reason).toBe({ live_writer: "writer_active", pending_side_effect: "external_action_uncertain",
|
|
uncertain_external_action: "external_action_uncertain", sandbox_in_use: "allocation_in_use" }[fault]);
|
|
expect(check.nextCheckAt === null).toBe(check.reason === "external_action_uncertain");
|
|
}, 60_000);
|
|
|
|
it("obtains a stop receipt for an allocation that was never stopped before continuing", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
const metadata = { ...lease.metadata }; delete metadata.remoteExecutionTermination;
|
|
await db.update(environmentLeases).set({ metadata }).where(eq(environmentLeases.id, f.lease.id));
|
|
await recoverRetainedWorkspace(f.input);
|
|
expect(f.runtime.retryPendingSandboxTeardown).toHaveBeenCalledOnce();
|
|
expect(await action(f.actionId)).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
const [stopped] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, f.lease.id));
|
|
expect(stopped.metadata?.remoteExecutionTermination).toMatchObject({ state: "stopped", leaseId: f.lease.id });
|
|
}, 60_000);
|
|
|
|
it("is discovered and settled by the recovery worker without any user action", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeLessThanOrEqual(Date.now());
|
|
const onRecovered = vi.fn(async () => undefined);
|
|
const work = await reconcileRetainedWorkspaces(db, f.environmentRuntime, onRecovered, true);
|
|
await Promise.all(work ?? []);
|
|
expect(await action(f.actionId)).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
expect(onRecovered).toHaveBeenCalledOnce();
|
|
const due = await nextWorkspaceRecoveryAt(db);
|
|
expect(due === null || due > Date.now() - 1).toBe(true);
|
|
expect(await retainedWorkspaceCandidates(db, undefined, false, true)).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ action: expect.objectContaining({ id: f.actionId }) })]));
|
|
}, 60_000);
|
|
|
|
/**
|
|
* The Nov 10 staging shape: a native run failed before provider work, so its
|
|
* prepared files sit under a no-replay hold that is recorded resolved but
|
|
* blocked. Its reusable sandbox history made the source unverifiable
|
|
* (allocation_ownership_ambiguous) and the task stayed blocked on a Retry
|
|
* that could never pass. The worker continues it from the last save.
|
|
*/
|
|
it("continues a resolved-but-blocked native no-replay hold from the last save, discovered by the worker", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const source = await run(f.runId);
|
|
await db.update(nativeRunFinalizations).set({ phase: "terminal_failure", resultId: null, recoveryState: "blocked",
|
|
failureCode: "native_continuation_requires_reconciliation" }).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
await db.update(heartbeatRuns).set({ errorCode: "native_continuation_requires_reconciliation",
|
|
error: "native_controller_recovery_missing_evidence:retained_sandbox_binding",
|
|
resultJson: { ...source.resultJson, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })
|
|
.where(eq(heartbeatRuns.id, f.runId));
|
|
const [task] = await db.update(issues).set({ status: "blocked", lastStatusDecisionId: null })
|
|
.where(eq(issues.id, f.issueId)).returning();
|
|
const held = await action(f.actionId);
|
|
const refused = "Paperclip refused to recover the saved files: Paperclip cannot prove which runs used the sandbox that holds the saved files "
|
|
+ "(allocation_ownership_ambiguous). Automatic recovery does not repeat this check, because the same saved state gives the same result. "
|
|
+ "Retry runs the check again and succeeds only after Paperclip is updated. The agent's result is saved, and the agent will not repeat its work.";
|
|
// The exact row the Nov 10 release saved: resolved with outcome blocked,
|
|
// replay blocked, and the task's Blocked status version recorded.
|
|
await db.update(issueRecoveryActions).set({ kind: "active_run_watchdog", cause: "native_continuation_requires_reconciliation",
|
|
status: "resolved", outcome: "blocked", resolvedAt: new Date(), ownerType: "board", nextAction: refused,
|
|
evidence: { ...held.evidence,
|
|
automaticRecovery: { runId: f.runId, policy: "preserve_without_replay_v1", replay: "blocked",
|
|
recordedAt: new Date().toISOString(), actionOutcome: "unknown" },
|
|
nativeFailureBlock: { runId: f.runId, statusVersion: Number(task.statusVersion) },
|
|
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [f.lease.id] },
|
|
workspaceRestoreFailure: "restore_failed", continuationDelivery: "invalidated",
|
|
workspaceRecovery: { ...(held.evidence.workspaceRecovery as object), message: refused } } })
|
|
.where(eq(issueRecoveryActions.id, f.actionId));
|
|
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeLessThanOrEqual(Date.now());
|
|
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => undefined, true) ?? []);
|
|
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ status: "resolved", outcome: "restored", ownerType: "system",
|
|
resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME, returnOwnerAgentId: source.agentId });
|
|
expect(settled.evidence.continuationDelivery).toBe("pending");
|
|
expect(settled.evidence.automaticRecovery).toBeUndefined();
|
|
for (const text of [settled.nextAction, readWorkspaceRecovery(settled.evidence)?.message])
|
|
expect(String(text)).not.toMatch(/succeeds only after|Paperclip is updated/);
|
|
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toMatchObject({ writerRunId: null, dirty: false,
|
|
location: null, snapshotId: f.snapshotId });
|
|
// The hold owned the Blocked status, so the task is ready again.
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("todo");
|
|
// Never a second executor: the abandoned source is never replaced.
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
|
expect(coordinator.failureDetail).toMatchObject({ replacementDenied: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
const wake = vi.fn(async () => ({ id: randomUUID() }));
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
await deliverReconciledExecutions(db, wake as never);
|
|
expect(wake.mock.calls.filter(call => (call as unknown[])[0] === source.agentId)).toHaveLength(1);
|
|
expect((await retainedWorkspaceCandidates(db, undefined, false, true)).map(row => row.action.id)).not.toContain(f.actionId);
|
|
}, 60_000);
|
|
|
|
it("does not settle a no-replay hold whose task moved on to a newer Blocked decision", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const [task] = await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, f.issueId)).returning();
|
|
const held = await action(f.actionId);
|
|
await db.update(issueRecoveryActions).set({ kind: "active_run_watchdog", cause: "native_continuation_requires_reconciliation",
|
|
status: "resolved", outcome: "blocked", resolvedAt: new Date(), evidence: { ...held.evidence,
|
|
automaticRecovery: { runId: f.runId, policy: "preserve_without_replay_v1", replay: "blocked" },
|
|
// A newer status decision replaced the Blocked status this run's failure set.
|
|
nativeFailureBlock: { runId: f.runId, statusVersion: Number(task.statusVersion) - 1 } } })
|
|
.where(eq(issueRecoveryActions.id, f.actionId));
|
|
await recoverRetainedWorkspace(f.input);
|
|
const settled = await action(f.actionId);
|
|
expect(settled).toMatchObject({ resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
expect(settled.evidence.continuationDelivery).toBe("not_requested");
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("blocked");
|
|
}, 60_000);
|
|
|
|
// The discovery probes find the source run by its primary key. They must
|
|
// still match only the canonical id text the hold saved, as the text
|
|
// comparison they replace did: a hold whose saved run id is not canonical
|
|
// is not discovered through them.
|
|
it("discovers a hold through its source run only by the canonical run id it saved", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const ids = async () => (await retainedWorkspaceCandidates(db, undefined, false, true)).map(row => row.action.id);
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeLessThanOrEqual(Date.now());
|
|
expect(await ids()).toContain(f.actionId);
|
|
const saved = await action(f.actionId);
|
|
await db.update(issueRecoveryActions).set({ evidence: { ...saved.evidence, runId: f.runId.toUpperCase() } })
|
|
.where(eq(issueRecoveryActions.id, f.actionId));
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeNull();
|
|
expect(await ids()).not.toContain(f.actionId);
|
|
await db.update(issueRecoveryActions).set({ evidence: saved.evidence }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
expect(await ids()).toContain(f.actionId);
|
|
}, 60_000);
|
|
|
|
it("waits until a deferred transient check is due before the worker visits the hold again", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const source = await run(f.runId);
|
|
const [writer] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: source.agentId, issueId: f.issueId,
|
|
runtimeMode: "native", status: "running", startedAt: new Date(), contextSnapshot: { issueId: f.issueId } }).returning();
|
|
const work = await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => undefined, true);
|
|
await Promise.all(work ?? []);
|
|
const deferred = await action(f.actionId);
|
|
expect(deferred.status).toBe("active");
|
|
const ids = async () => (await retainedWorkspaceCandidates(db, undefined, false, true)).map(row => row.action.id);
|
|
expect(await ids()).not.toContain(f.actionId);
|
|
expect(await nextWorkspaceRecoveryAt(db)).toBeGreaterThan(Date.now());
|
|
// Once the writer ended and the check is due, the worker settles the hold.
|
|
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, writer.id));
|
|
await db.update(issueRecoveryActions).set({ evidence: { ...deferred.evidence, retainedSourceFallbackCheck: {
|
|
...(deferred.evidence.retainedSourceFallbackCheck as object), nextCheckAt: Date.now() - 1 } } }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
expect(await ids()).toContain(f.actionId);
|
|
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => undefined, true) ?? []);
|
|
expect(await action(f.actionId)).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
}, 60_000);
|
|
|
|
it("continues once the reconciler settles a final save a restart left running after its run ended", async () => {
|
|
const f = await unverifiableRetainedSource();
|
|
const [save] = await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
|
|
heartbeatRunId: f.runId, issueId: f.issueId, phase: "remote_workspace_snapshot", status: "running",
|
|
metadata: { epoch: 8, boundary: "final", generation: 99, exportCleanup: { state: "pending" },
|
|
saveWork: { schema: "paperclip.remote-workspace-save-work.v1", activeIds: [], unknown: false, quiescedAt: new Date(Date.now() - 60_000).toISOString() } },
|
|
}).returning();
|
|
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => undefined, true) ?? []);
|
|
const deferred = await action(f.actionId);
|
|
expect(deferred.status).toBe("active");
|
|
expect(deferred.evidence.retainedSourceFallbackCheck).toMatchObject({ reason: "workspace_operation_running" });
|
|
// Twelve refusals spend the check budget: no further check is scheduled.
|
|
await db.update(issueRecoveryActions).set({ evidence: { ...deferred.evidence, retainedSourceFallbackCheck: {
|
|
...(deferred.evidence.retainedSourceFallbackCheck as object), checks: 12, nextCheckAt: null } } }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
expect((await retainedWorkspaceCandidates(db, undefined, false, true)).map(row => row.action.id)).not.toContain(f.actionId);
|
|
|
|
await runOwnershipReconciliationPass(db, { state: createOwnershipReportState() });
|
|
expect((await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, save.id)))[0])
|
|
.toMatchObject({ status: "failed", metadata: { abandonedSave: { reason: "abandoned_remote_snapshot_save" } } });
|
|
expect((await action(f.actionId)).evidence.retainedSourceFallbackCheck).toBeUndefined();
|
|
await Promise.all(await reconcileRetainedWorkspaces(db, f.environmentRuntime, async () => undefined, true) ?? []);
|
|
expect(await action(f.actionId)).toMatchObject({ status: "resolved", resolutionNote: RETAINED_SOURCE_FALLBACK_OUTCOME });
|
|
}, 60_000);
|
|
|
|
it("settles a stale exhausted hold as recovered when a sibling action saved the run's files", async () => {
|
|
const f = await acceptedRemoteExport();
|
|
await recoverRetainedWorkspace(f.input);
|
|
expect(await hasVerifiedWorkspaceRepair(db, await run(f.runId))).toBe(true);
|
|
await db.update(issueRecoveryActions).set({ status: "resolved", resolvedAt: new Date() }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
const staleId = randomUUID(), now = Date.now();
|
|
// An export hold that names the task's Blocked status leaves the status to
|
|
// its saved result's finalization; the sibling settlement must not change it.
|
|
const [task] = await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, f.issueId)).returning();
|
|
await db.insert(issueRecoveryActions).values({ id: staleId, companyId: f.companyId, sourceIssueId: f.issueId,
|
|
kind: "active_run_watchdog", status: "active", ownerType: "board", cause: "native_workspace_sync_out_retry_exhausted",
|
|
fingerprint: randomUUID(), nextAction: "Automatic recovery of saved work stopped after 6 attempts.",
|
|
evidence: { runId: f.runId, nativeFailureBlock: { runId: f.runId, statusVersion: Number(task.statusVersion) },
|
|
systemRecovery: { schema: "paperclip.system-recovery.v1", policyVersion: 1, sourceRunId: f.runId, revision: 54,
|
|
generationId: randomUUID(), intentId: null, phase: "preserve_files", state: "exhausted", attemptId: randomUUID(),
|
|
attempts: 6, retriesUsed: 5, firstFailureAt: now - 60_000, nextAttemptAt: null, deadlineAt: now - 1, providerCooldownAt: null,
|
|
failure: { code: "export_interrupted", retryClass: "retry_transient", evidenceFingerprint: "b".repeat(64) },
|
|
preservationOperationIds: [], freshAttemptRunId: null },
|
|
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: f.runId, status: "blocked", stage: "restoring_files",
|
|
attempt: 6, attemptLimit: 6, retryable: true, requestIds: [] } } });
|
|
|
|
await recoverRetainedWorkspace({ ...f.input, actionId: staleId });
|
|
|
|
const stale = await action(staleId);
|
|
expect(stale).toMatchObject({ status: "resolved", outcome: "restored", ownerType: "system",
|
|
resolutionNote: "workspace_recovered_by_sibling_action" });
|
|
expect(readWorkspaceRecovery(stale.evidence)?.status).toBe("recovered");
|
|
expect(stale.evidence.siblingWorkspaceRecovery).toMatchObject({ recoveryActionId: f.actionId });
|
|
expect(String(stale.nextAction)).not.toMatch(adminText);
|
|
const live = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, f.issueId),
|
|
inArray(issueRecoveryActions.status, ["active", "escalated"])));
|
|
expect(live).toHaveLength(0);
|
|
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!.status).toBe("blocked");
|
|
}, 60_000);
|
|
|
|
it("stops discovering a stale hold whose sibling receipt no longer verifies", async () => {
|
|
const f = await acceptedRemoteExport();
|
|
await recoverRetainedWorkspace(f.input);
|
|
await db.update(issueRecoveryActions).set({ status: "resolved", resolvedAt: new Date() }).where(eq(issueRecoveryActions.id, f.actionId));
|
|
// A later writer advanced the workspace: the sibling's receipt no longer verifies.
|
|
const ws = await workspace(f.workspaceId);
|
|
await db.update(executionWorkspaces).set({ metadata: { ...ws.metadata, remoteWorkspace: {
|
|
...readRemoteWorkspaceAuthority(ws.metadata)!, dirty: true, writerRunId: randomUUID() } } }).where(eq(executionWorkspaces.id, f.workspaceId));
|
|
expect(await hasVerifiedWorkspaceRepair(db, await run(f.runId))).toBe(false);
|
|
const staleId = randomUUID(), now = Date.now();
|
|
await db.insert(issueRecoveryActions).values({ id: staleId, companyId: f.companyId, sourceIssueId: f.issueId,
|
|
kind: "active_run_watchdog", status: "active", ownerType: "board", cause: "native_workspace_sync_out_retry_exhausted",
|
|
fingerprint: randomUUID(), nextAction: "Stale",
|
|
evidence: { runId: f.runId,
|
|
systemRecovery: { schema: "paperclip.system-recovery.v1", policyVersion: 1, sourceRunId: f.runId, revision: 3,
|
|
generationId: randomUUID(), intentId: null, phase: "preserve_files", state: "exhausted", attemptId: randomUUID(),
|
|
attempts: 6, retriesUsed: 5, firstFailureAt: now - 60_000, nextAttemptAt: null, deadlineAt: now - 1, providerCooldownAt: null,
|
|
failure: { code: "export_interrupted", retryClass: "retry_transient", evidenceFingerprint: "c".repeat(64) },
|
|
// Exhausted under the current capture policy, so only the sibling rule applies.
|
|
preservationOperationIds: [], freshAttemptRunId: null, capturePolicyVersion: REMOTE_WORKSPACE_CAPTURE_POLICY_VERSION },
|
|
workspaceRecovery: { schema: "paperclip.workspace-recovery.v1", runId: f.runId, status: "blocked", stage: "restoring_files",
|
|
attempt: 6, attemptLimit: 6, retryable: true, requestIds: [] } } });
|
|
const ids = async () => (await retainedWorkspaceCandidates(db, undefined, false, true)).map(row => row.action.id);
|
|
expect(await ids()).toContain(staleId);
|
|
await recoverRetainedWorkspace({ ...f.input, actionId: staleId });
|
|
const stale = await action(staleId);
|
|
expect(stale.status).toBe("active");
|
|
expect(stale.evidence.retainedSourceFallbackCheck).toMatchObject({ reason: "sibling_receipt_unverified", nextCheckAt: null });
|
|
expect(await ids()).not.toContain(staleId);
|
|
}, 60_000);
|
|
});
|