Files
PaperClipAI/server/src/services/quarantined-task-retry.test.ts
T
DottaandClaude Opus 5.5 a94881d1a7 fix(heartbeat): save a comment refused by a temporary hold and route post-quarantine input to its own gate (#19829)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user's follow-up comment or Retry must be delivered once,
eventually, even when a temporary company admission hold (accounting
still settling) is active
> - #19741 kept claimed runs queued through such a hold and woke held
work when it cleared
> - A restart-chaos wave still lost a comment, because its wake was
refused before admission and written as a terminal skip
> - The same wave left a Retry waiting forever for a stop record of a
process that never existed
> - This pull request saves the refused comment as a deferred receipt,
and routes input after a pre-launch quarantine failure to the
quarantine's own gate
> - The benefit is that the comment is delivered after the hold, and the
task asks for the action that actually unblocks it instead of offering a
Retry that can never start

## Linked Issues or Issue Description

Refs #19741 and #19702.

**What happened?**
In a restart-chaos wave, a failed run's accounting held the company for
600 s.
1. A user commented while the task's run was live. The enqueue budget
gate refused the comment's wake before it reached the deferral behind
the live run. Because it was a user request with no saved receipt, it
was written as `skipped` (`budget.blocked`). The live run finished
without it and the task ended `done`, so the comment was lost.
2. A Retry created a run that the native cleanup-quarantine fence
refused before it requested any runner launch
(`native_session_cleanup_quarantined`, no process events). A second
Retry then waited with `process_identity_missing` ("The previous run has
no verified stop record") for a process that never existed. The task
kept offering Retry, and that Retry could never start.

**Expected behavior**
A comment refused by a temporary hold stays saved and is delivered once
later. Input after a run that was refused before launch is handled by
the quarantine gate: Retry is not offered (send a new message), and a
new message gets the verified-stop check of the previous provider
session.

## What Changed

- `heartbeat/queue.ts`: when the enqueue budget gate refuses a user's
comment wake and the block is temporary (not a reached hard stop, per
`isBoardOwnedBudgetBlock`), the wake is saved as a
`deferred_issue_execution` receipt. The receipt carries the comment ids
and an `executionWait` of `budget.blocked`, and the gate notifies the
deferred-receipt worker and throws `PersistedExecutionWaitRefusal`. It
is not written as `skipped`. Normal promotion (when the live run ends)
and #19741's hold-release wakeups deliver it.
- `native-local-process-stop.ts`: new `nativeRunRefusedBeforeLaunch`. It
returns true for a failed native run with
`native_session_cleanup_quarantined`, no recorded process, and no
launch-request or identity event.
- `explicit-native-continuation.ts`: such a run passes the
process-identity check, so the existing quarantine branch decides. A
Retry gets `cleanup_quarantined` (shown with `canRetry: false` and "send
a new message"). A new message gets
`verifyStoppedNativeSessionForContinuation`. Any launch request or
identity record keeps the original stop proofs.

- `native-session-executor.ts`
(`verifyStoppedNativeSessionForContinuation`): when the source has no
process record and no launch event, the verifier relies on the previous
owner's stop evidence. Before this change it required the source to
share the owner's process identity, so a new message stayed held even
after the owner stopped. A source that carries a process identity must
still match the owner exactly.

## Verification

Each new test fails on `master` and passes with this change, on embedded
Postgres:
- `server/src/__tests__/heartbeat-held-comment-wake.test.ts`: a comment
whose wake the hold refuses at enqueue is saved with a `budget.blocked`
wait and is not skipped. It is delivered exactly once after accounting
settles.
- `server/src/services/explicit-native-continuation.test.ts`:
- A run refused by the quarantine before launch routes a new message to
the quarantine's stop verification and a Retry to `cleanup_quarantined`.
These failed on `master` with `process_identity_missing`.
- Guards (green on both): a quarantined run that requested a launch, and
another failure without a process record, still require
`process_identity_missing`.
- `server/src/services/quarantined-task-retry.test.ts`: a never-launched
quarantined source is certified once the previous owner has stopped (red
on `master`). It stays held if the source requested a launch or the
owner is still running. All 31 tests pass, and the `stopped native
conversation physical cleanup` suite passes (46).
- Related suites pass locally (6 files, 620 tests): explicit native
continuation, held comment wake, queued comments routes, comment wake
batching, refused assignment wake, deferred receipt work. Two
`deferred-execution-receipt-work` tests failed once under parallel load
and passed when run alone.

## Risks

- The saved-comment path applies only to user comment wakes refused by a
temporary budget hold. Hard stops, system wakes, case handoffs, durable
chat requests, and existing saved receipts are unchanged.
- The pre-launch rule is limited to the quarantine failure code with no
launch evidence. Every other missing process identity still requires the
existing stop proof.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, run as an agent in
Claude Code with tool use (shell, file editing, local test execution,
kept restart-chaos instance inspection).

## 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-07 08:54:14 -06:00

351 lines
27 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { hostname, tmpdir } from "node:os";
import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents, agentWakeupRequests, companies, createDb, environmentLeases, executionWorkspaces, heartbeatRunEvents,
heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations, workspaceOperations,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { executeNativeSession, nativeSessionCleanupBlock, onNativeSessionCleanupSettled, parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
import type { NativeSession } from "../vendor/paperclip-runner/index.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import { heartbeatService } from "./heartbeat.js";
import { getExecutionBlocker } from "./execution-blocker.js";
import { readFreshWorkspaceReadiness } from "./fresh-workspace-continuation.js";
import { instanceSettingsService } from "./instance-settings.js";
import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
import { nativeSha256 } from "./native-runtime/canonical.js";
import { verifyStoppedNativeSessionForContinuation } from "./native-runtime/native-session-executor.js";
import { requestTaskRetry } from "./task-retry.js";
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "./delivery-work-notifications.js";
import { registerNativeCleanupReleaseWake } from "./native-runtime/native-cleanup-release-wake.js";
describe("task Retry after inherited native cleanup quarantine", () => {
let postgres: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
postgres = await startEmbeddedPostgresTestDatabase("quarantined-task-retry-");
db = createDb(postgres.connectionString);
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true });
}, 90_000);
afterAll(async () => { await db?.$client.end(); await postgres?.cleanup(); });
async function seed(base: string, transient = false) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const ownerId = randomUUID(), sourceId = randomUUID(), nativeSessionId = randomUUID(), runnerInstanceId = randomUUID();
const workspaceId = randomUUID();
const execution = (runId: string) => parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v1", provider: { kind: "codex", model: null },
binding: { companyId, agentId, issueId, runId, executionWorkspaceId: transient ? runId : workspaceId },
task: { identifier: "TEST", title: "Continue saved work", description: null, prompt: "Continue saved work", workMode: "standard" },
workspace: { cwd: base, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: nativeSessionId, driverKind: "codex_app_server", protocolVersion: 1,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
completionContract: { id: "contract", sha256: "sha", schemaVersion: "paperclip.completion-contract.v1",
contract: { revision: "1", objective: "Continue saved work", criteria: [{ id: "done", requirement: "Save the result" }] } },
interactionResponses: [], credentialBindings: [],
});
const processStartedAt = new Date(Date.now() - 120_000);
const processIdentity = { processPid: 99_999_999, processGroupId: 99_999_999, processStartedAt };
await db.insert(companies).values({ id: companyId, name: "Quarantined Retry", defaultResponsibleUserId: "board",
issuePrefix: `Q${companyId.replaceAll("-", "")}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Native worker", adapterType: "paperclip_runner", status: "idle",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } });
await db.insert(issues).values({ id: issueId, companyId, title: "Continue saved work", status: "blocked", assigneeAgentId: agentId });
if (!transient) {
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId,
mode: "shared_workspace", strategyType: "project_primary", name: "Saved work", cwd: base });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issueId));
}
const resultJson = { savedAnswer: "Existing work", recoveredExecutionFailure: { errorCode: "provider_transport_failed" } };
for (const runId of [ownerId, sourceId]) {
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, nativeIssueId: issueId,
runtimeMode: "native", nativeSessionId, runnerInstanceId, ...processIdentity,
status: runId === ownerId ? "succeeded" : "failed", resultJson: runId === ownerId ? resultJson : null,
errorCode: runId === sourceId ? "native_session_cleanup_quarantined" : null,
createdAt: new Date(Date.now() - (runId === ownerId ? 110_000 : 60_000)),
startedAt: new Date(Date.now() - (runId === ownerId ? 109_000 : 59_000)),
finishedAt: new Date(Date.now() - (runId === ownerId ? 100_000 : 50_000)),
contextSnapshot: { issueId }, runnerProfileJson: { nativeExecutionInput: execution(runId),
nativeToolContractFingerprint: `sha256:${"1".repeat(64)}`, sessionCheckpoint: { sessionId: "retained-provider-thread" } },
});
await db.insert(nativeRunFinalizations).values({ companyId, issueId, runId,
phase: runId === ownerId ? "committed" : "terminal_failure" });
await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId,
provider: "local", status: "failed", leasePolicy: "ephemeral", releasedAt: new Date(Date.now() - 40_000) });
}
await appendHeartbeatRunEvent(db, { companyId, runId: ownerId, agentId, eventType: PROCESS_IDENTITY_RECORDED,
payload: { ...processIdentity, processStartedAt: processStartedAt.toISOString(), targetKind: "local", hostname: hostname() } });
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId, kind: "active_run_watchdog",
cause: "native_session_cleanup_quarantined", fingerprint: sourceId, status: "resolved", outcome: "blocked",
nextAction: "Automatic recovery stopped.", evidence: { runId: sourceId,
automaticRecovery: { replay: "blocked", actionOutcome: "unknown" } } });
const identity = { runId: ownerId, runnerInstanceId, normalizedSessionId: nativeSessionId,
environmentLeaseId: transient ? ownerId : workspaceId, turnId: `turn-${ownerId}`, itemId: `item-${ownerId}` };
const event = { schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceKind: "runner", sourceSeq: 5,
sourceEventId: "provider-open", sourceInstanceId: runnerInstanceId, runId: ownerId,
normalizedSessionId: nativeSessionId, turnId: identity.turnId, itemId: identity.itemId,
priority: 0, emittedAt: processStartedAt.toISOString(), eventType: "session.resumed",
payload: { providerSessionId: "retained-provider-thread", processId: 99_999_998 } };
const normalized = { ...event, sourceSeq: 1, sourceEventId: `${runnerInstanceId}:${ownerId}:1`, turnId: undefined, itemId: undefined,
payload: { providerSessionId: "retained-provider-thread", driverSessionId: "retained-provider-thread" } };
await appendHeartbeatRunEvent(db, { companyId, runId: ownerId, agentId, eventType: event.eventType,
payload: { prpEvent: normalized }, nativeSource: { sourceInstanceId: runnerInstanceId,
sourceEventId: normalized.sourceEventId, sourceSeq: 1, protocolSchemaVersion: 1, canonicalPayload: normalized } });
const root = join(base, nativeSha256({ schema: "paperclip.native-session-scope.v2", companyId, agentId,
workspace: transient ? { kind: "transient", cwd: base, repoUrl: null, repoRef: null, branchName: null }
: { kind: "managed", executionWorkspaceId: workspaceId },
provider: { driverKind: "codex_app_server", identity: { kind: "codex" } }, normalizedSessionId: nativeSessionId }));
const files = {
"control-plane/control-plane-state.json": { schema: "paperclip.runner.durable.control-plane-state.v1", identity,
committedEvents: [{ envelope: { payload: event } }], commands: [{ status: "pending", payload: { commandType: "tool.result" } }] },
"runner/runner-state.json": { schema: "paperclip.runner.durable.state.v1", ...identity, lifecycle: "suspended", outbox: [] },
"runner/codex-provider-state.json": { schema: "paperclip.runner.codex-provider-state.v1", lifecycle: "session_open",
threadId: "retained-provider-thread", startupAttempt: null, pendingEvents: [], queuedEvents: [] },
};
await mkdir(join(root, "control-plane"), { recursive: true });
await mkdir(join(root, "runner"), { recursive: true });
for (const [path, value] of Object.entries(files)) await writeFile(join(root, path), JSON.stringify(value));
await writeFile(join(base, "saved-work.txt"), "Keep the prior result and uncertain action history.");
return { companyId, agentId, issueId, workspaceId, ownerId, sourceId, runnerInstanceId, resultJson, root, files };
}
it.each(["preserved", "projectless", "changed_binding", "changed_cwd", "pending_operation", "active_lease", "closed_workspace", "remote_workspace", "foreign_receipt"])(
"admits one fresh successor through canonical Retry without replaying the retained session (%s)", async mode => {
const base = await mkdtemp(join(tmpdir(), "quarantined-retry-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
try {
const f = await seed(base, mode === "projectless");
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const input = { db, companyId: f.companyId, issueId: f.issueId, actorId: "board", actorSource: "local_implicit", observedRunId: f.sourceId };
const receipt = await requestTaskRetry({ ...input, idempotencyKey: randomUUID() });
const duplicate = await requestTaskRetry({ ...input, idempotencyKey: randomUUID() });
expect(duplicate.requestId).toBe(receipt.requestId);
const heartbeat = heartbeatService(db);
await Promise.all([heartbeat.resumeTaskRetry(f.companyId, receipt.requestId!), heartbeat.resumeTaskRetry(f.companyId, receipt.requestId!)]);
const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
const [state] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!));
expect(successors, JSON.stringify(state.payload)).toHaveLength(1);
expect(successors[0].contextSnapshot).toMatchObject({ previousRunId: f.sourceId, forceFreshSession: true, taskRetryRequestId: receipt.requestId });
if (mode === "changed_binding") await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
if (mode === "changed_cwd") await db.update(executionWorkspaces).set({ cwd: join(base, "another-directory") }).where(eq(executionWorkspaces.id, f.workspaceId));
if (mode === "closed_workspace") await db.update(executionWorkspaces).set({ status: "closed" }).where(eq(executionWorkspaces.id, f.workspaceId));
if (mode === "remote_workspace") await db.update(executionWorkspaces).set({ providerType: "plugin" }).where(eq(executionWorkspaces.id, f.workspaceId));
if (mode === "pending_operation") await db.insert(workspaceOperations).values({ companyId: f.companyId,
executionWorkspaceId: f.workspaceId, heartbeatRunId: f.ownerId, issueId: f.issueId, phase: "workspace_restore", status: "running" });
if (mode === "active_lease") await db.insert(environmentLeases).values({ companyId: f.companyId,
executionWorkspaceId: f.workspaceId, heartbeatRunId: f.ownerId, issueId: f.issueId, provider: "local", status: "active", leasePolicy: "ephemeral" });
if (mode === "foreign_receipt") {
const [proof] = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.sourceId),
eq(heartbeatRunEvents.eventType, "native.stopped_conversation_verified")));
await db.update(heartbeatRunEvents).set({ payload: { ...proof.payload, successorRunId: randomUUID() } })
.where(eq(heartbeatRunEvents.id, proof.id));
expect(await readFreshWorkspaceReadiness(db, successors[0])).toMatchObject({ mode: "fresh_isolated_workspace" });
} else if (mode === "projectless") {
expect(await readFreshWorkspaceReadiness(db, successors[0])).toMatchObject({ mode: "fresh_isolated_workspace",
retryIntentId: receipt.requestId, sourceRunId: f.sourceId });
} else if (mode === "preserved") {
expect(await readFreshWorkspaceReadiness(db, successors[0])).toMatchObject({ mode: "resume_stopped_native_workspace",
retryIntentId: receipt.requestId, sourceRunId: f.sourceId, workspaceId: f.workspaceId, cwd: base });
} else await expect(readFreshWorkspaceReadiness(db, successors[0])).rejects.toThrow(/Verified native continuation workspace/);
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!));
expect(wake).toMatchObject({ status: "coalesced", runId: successors[0].id });
const [owner] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.ownerId));
expect(owner.resultJson).toEqual(f.resultJson);
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
expect(action.evidence.automaticRecovery).toMatchObject({ actionOutcome: "unknown", replay: "explicit_user_continuation" });
const proof = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.sourceId), eq(heartbeatRunEvents.eventType, "native.stopped_conversation_verified")));
expect(proof).toHaveLength(1);
expect(proof[0].payload).toMatchObject({ runId: f.sourceId, ownerRunId: f.ownerId });
for (const [path, value] of Object.entries(f.files)) expect(await readFile(join(f.root, path), "utf8")).toBe(JSON.stringify(value));
expect(await readFile(join(base, "saved-work.txt"), "utf8")).toContain("Keep the prior result");
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(base, { recursive: true, force: true });
}
});
it.each(["provider_alive", "worker_alive", "owner_running", "controller_owned", "owner_remote", "owner_unreleased",
"owner_new_launch", "source_new_launch", "foreign_host", "foreign_workspace", "foreign_cwd", "foreign_runner", "missing_provider_receipt",
"pending_provider_launch", "changed_process_birth", "changed_journal", "normalized_receipt_identity", "provider_identity_conflict"])("keeps quarantine for %s", async mode => {
const base = await mkdtemp(join(tmpdir(), "quarantined-retry-fence-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
try {
const f = await seed(base);
if (mode === "provider_alive" || mode === "pending_provider_launch") {
await writeFile(join(f.root, "runner/codex-provider-state.json"), JSON.stringify({
...f.files["runner/codex-provider-state.json"],
...(mode === "provider_alive" ? { processId: process.pid } : { startupAttempt: { phase: "intent" } }),
}));
}
if (mode === "worker_alive") {
await db.update(heartbeatRuns).set({ processPid: process.pid, processGroupId: process.pid })
.where(eq(heartbeatRuns.companyId, f.companyId));
const [owner] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.ownerId));
await appendHeartbeatRunEvent(db, { companyId: f.companyId, runId: f.ownerId, agentId: f.agentId,
eventType: PROCESS_IDENTITY_RECORDED, payload: { targetKind: "local", hostname: hostname(),
processPid: process.pid, processGroupId: process.pid, processStartedAt: owner.processStartedAt!.toISOString() } });
}
if (mode === "owner_running") await db.update(heartbeatRuns).set({ status: "running", finishedAt: null }).where(eq(heartbeatRuns.id, f.ownerId));
if (mode === "controller_owned") await db.update(nativeRunFinalizations).set({ leaseOwner: "other-controller" }).where(eq(nativeRunFinalizations.runId, f.ownerId));
if (mode === "owner_remote" || mode === "owner_unreleased") await db.update(environmentLeases).set(
mode === "owner_remote" ? { provider: "daytona" } : { releasedAt: null },
).where(eq(environmentLeases.heartbeatRunId, f.ownerId));
if (mode === "owner_new_launch" || mode === "source_new_launch") await appendHeartbeatRunEvent(db, {
companyId: f.companyId, runId: mode === "owner_new_launch" ? f.ownerId : f.sourceId, agentId: f.agentId, eventType: PROCESS_START_REQUESTED,
});
if (mode === "foreign_host") {
const [identity] = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.ownerId),
eq(heartbeatRunEvents.eventType, PROCESS_IDENTITY_RECORDED)));
await db.update(heartbeatRunEvents).set({ payload: { ...identity.payload, hostname: "another-host" } })
.where(eq(heartbeatRunEvents.id, identity.id));
}
if (mode === "changed_process_birth") await db.update(heartbeatRuns).set({ processStartedAt: new Date() }).where(eq(heartbeatRuns.id, f.sourceId));
if (mode === "foreign_runner") await db.update(heartbeatRuns).set({ runnerInstanceId: randomUUID() }).where(eq(heartbeatRuns.id, f.ownerId));
if (mode === "foreign_workspace" || mode === "foreign_cwd") {
const [owner] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.ownerId));
const input = parseNativeExecutionInput(owner.runnerProfileJson!.nativeExecutionInput);
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...owner.runnerProfileJson,
nativeExecutionInput: { ...input,
...(mode === "foreign_workspace" ? { binding: { ...input.binding, executionWorkspaceId: "another-workspace" } }
: { workspace: { ...input.workspace, cwd: join(base, "another-directory") } }),
},
} }).where(eq(heartbeatRuns.id, f.ownerId));
}
if (mode === "missing_provider_receipt") await db.delete(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.runId, f.ownerId), eq(heartbeatRunEvents.eventType, "session.resumed")));
if (mode === "normalized_receipt_identity") await db.update(heartbeatRunEvents).set({ sourceSeq: 2 }).where(and(
eq(heartbeatRunEvents.runId, f.ownerId), eq(heartbeatRunEvents.eventType, "session.resumed")));
if (mode === "provider_identity_conflict") {
const [receipt] = await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, f.ownerId),
eq(heartbeatRunEvents.eventType, "session.resumed")));
const event = receipt.payload!.prpEvent as Record<string, unknown>;
const changed = { ...event, payload: { providerSessionId: "another-thread", driverSessionId: "another-thread" } };
await db.update(heartbeatRunEvents).set({ payload: { prpEvent: changed }, sourcePayloadSha256: nativeSha256(changed) })
.where(eq(heartbeatRunEvents.id, receipt.id));
}
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceId));
const proof = await verifyStoppedNativeSessionForContinuation(db, source);
if (mode === "changed_journal") {
expect(proof).not.toBeNull();
await writeFile(join(f.root, "runner/runner-state.json"), "{}");
expect(proof!.retire()).toBe(false);
} else expect(proof).toBeNull();
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(base, { recursive: true, force: true });
}
});
it.each(["stopped", "source_launch_requested", "owner_running"])(
"certifies a quarantined source that never launched from the previous owner's stop (%s)", async mode => {
// s7532/t04: the quarantine fence refused the source before it asked for
// a runner, so it has no process record at all. Only the owner can prove
// the retained session stopped.
const base = await mkdtemp(join(tmpdir(), "quarantined-never-launched-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
try {
const f = await seed(base);
await db.update(heartbeatRuns).set({ processPid: null, processGroupId: null, processStartedAt: null })
.where(eq(heartbeatRuns.id, f.sourceId));
if (mode === "source_launch_requested") await appendHeartbeatRunEvent(db, {
companyId: f.companyId, runId: f.sourceId, agentId: f.agentId, eventType: PROCESS_START_REQUESTED });
if (mode === "owner_running") await db.update(heartbeatRuns).set({ status: "running", finishedAt: null }).where(eq(heartbeatRuns.id, f.ownerId));
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceId));
const proof = await verifyStoppedNativeSessionForContinuation(db, source);
if (mode === "stopped") expect(proof).not.toBeNull();
else expect(proof).toBeNull();
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(base, { recursive: true, force: true });
}
});
it("holds a Retry while the task's own provider session close is still retained, then starts it", async () => {
const base = await mkdtemp(join(tmpdir(), "retained-close-retry-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = base;
let releaseClose = () => {};
try {
const f = await seed(base);
// The source failed for another reason; only the runner's in-process
// admission gate still holds this task's provider session close.
await db.update(heartbeatRuns).set({ errorCode: "provider_transport_failed" }).where(eq(heartbeatRuns.id, f.sourceId));
const settled = vi.fn();
const unsubscribe = onNativeSessionCleanupSettled(settled);
// The production release wake, observed on the delivery queue it signals.
const wakeFailure = vi.fn();
const unregisterWake = registerNativeCleanupReleaseWake(db, wakeFailure);
const deferredWake = vi.fn();
const unsubscribeDeferred = subscribeDeliveryWork(db, DELIVERY_QUEUES.deferredExecutionReceipts, deferredWake);
const pendingClose = new Promise<void>(resolve => { releaseClose = resolve; });
const identity = { runId: randomUUID(), sessionId: "retained-close-session", companyId: f.companyId, issueId: f.issueId, agentId: f.agentId };
const capabilities = { resume: true, typedEvents: true, steering: false, interruption: false, structuredResult: true };
const session: NativeSession = {
identity: () => identity, capabilities: async () => capabilities,
async *events() { throw new Error("retained close transport stopped"); },
startTurn: async () => ({ turnId: "retained-close-turn" }), result: async () => null,
async snapshot() { return { backendKind: "mock", sessionId: "retained-close-driver", identity,
providerSessionId: "retained-close-provider", cursor: null, activeTurnId: null, pendingRuntimeRequests: [], lineage: [] }; },
close: () => pendingClose,
};
await expect(executeNativeSession({
input: parseNativeExecutionInput({ schema: "paperclip.native-execution-input.v1", provider: { kind: "codex", model: null },
binding: { companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, runId: identity.runId, executionWorkspaceId: f.workspaceId },
task: { identifier: "TEST", title: "Continue saved work", description: null, prompt: "Continue saved work", workMode: "standard" },
workspace: { cwd: base, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: identity.sessionId, driverKind: "codex_app_server", protocolVersion: 1,
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } },
completionContract: { id: "contract", sha256: "sha", schemaVersion: "paperclip.completion-contract.v1",
contract: { revision: "1", objective: "Continue saved work", criteria: [{ id: "done", requirement: "Save the result" }] } },
interactionResponses: [], credentialBindings: [] }),
backend: { descriptor: async () => ({ kind: "runner", name: "retained-close-backend", version: "1", capabilities }),
openSession: async () => session },
controlPlane: { openRun: async () => {}, checkpointSession: async () => {},
appendEvent: async () => ({ cursor: 0, highestContiguousSourceSeq: 0, disposition: "committed" }),
replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }), completeRun: async () => {} },
runnerInstanceId: f.runnerInstanceId, controlPlaneInstanceId: "control",
})).rejects.toThrow("retained close transport stopped");
expect(nativeSessionCleanupBlock({ companyId: f.companyId, issueId: f.issueId })).toBe("pending");
expect(nativeSessionCleanupBlock({ companyId: f.companyId, issueId: randomUUID() })).toBeNull();
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const receipt = await requestTaskRetry({ db, companyId: f.companyId, issueId: f.issueId, actorId: "board",
actorSource: "local_implicit", observedRunId: f.sourceId, idempotencyKey: randomUUID() });
const heartbeat = heartbeatService(db);
await heartbeat.resumeTaskRetry(f.companyId, receipt.requestId!);
const queued = () => db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
// No successor is dispatched that the runner would refuse at once.
expect(await queued()).toHaveLength(0);
const [held] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, receipt.requestId!));
expect(held).toMatchObject({ status: "deferred_issue_execution", runId: null });
expect(held.payload).toMatchObject({ executionWait: { reason: "local_cleanup" } });
// The close settles by itself; its release wakes the held Retry, which then starts.
releaseClose();
await vi.waitFor(() => expect(settled).toHaveBeenCalledWith({ companyId: f.companyId, issueIds: [f.issueId] }));
await vi.waitFor(() => expect(deferredWake).toHaveBeenCalled());
expect(wakeFailure).not.toHaveBeenCalled();
expect(nativeSessionCleanupBlock({ companyId: f.companyId, issueId: f.issueId })).toBeNull();
unsubscribe();
unregisterWake();
unsubscribeDeferred();
// The deferred-execution worker runs this pass on that wake.
await heartbeat.resumeTaskRetry(f.companyId, receipt.requestId!);
expect(await queued()).toHaveLength(1);
} finally {
releaseClose();
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(base, { recursive: true, force: true });
}
});
});