fix(native): let a Stop before the provider session opens continue once (#19206)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A remote task restores its saved workspace into a sandbox before the
agent starts.
> - When that restore fails, the run records only
`remote_workspace_operation_failed: operation=restore exit=1`.
> - The run then fails as `setup_failed`. No retry starts, and a
`blocked` task gets no recovery action.
> - An operator cannot tell which step failed, and nobody owns the next
step.
> - This pull request makes the sandbox scripts name their failed step
in a closed grammar, and it gives a restore failure a bounded retry or a
named board recovery action.
> - The benefit is a visible cause and a clear owner. Saved work stays
protected.

## Linked Issues or Issue Description

Refs #19152 and #19187. Those changes name copy-back (save) failures.
Refs #18852 and #19195. Those changes added the bounded bootstrap retry
that this change reuses.

**What happened?**

A board reply woke an agent on a `blocked` parent task. The run failed
during setup 13 seconds later. The only run event was
`remote_workspace_operation_failed: operation=restore exit=1`. The run
had no workspace operation row. The task stayed `blocked`, and its
recovery actions showed `active: null`. The same opaque family also
appeared for workspace preparation (`exit=1` only).

**Expected behavior**

The run names which restore step failed, without command output or
paths. A failure before provider work uses a bounded retry when a retry
can help. Otherwise, and when the retry budget is spent, the board gets
a named recovery action. The saved version is never changed or
overwritten.

**Steps to reproduce**

1. Save a remote workspace, then start a new run on a different
allocation.
2. Put a folder at the published workspace path in the sandbox before
restore.
3. Observe that the base code records only `operation=restore exit=1`,
then `setup_failed`, with no recovery action for a `blocked` task.

**Paperclip version or commit**

Base: `87dfba6818`.

**Deployment mode**

Legacy runtime with sandbox (Daytona) remote workspace storage.

## What Changed

- The prepare, repository, and restore sandbox scripts track their
current stage. On failure they print `remote_workspace_<op>_failed:
stage=<stage>` plus closed fields: `reason=` (the script's own
snake_case refusal), `archive=` (the archive helper's fixed reason),
`code=` (errno), `disk=full`, `git=` (a fixed label chosen from Git
output), and the existing `exit`/`signal`/`program` fields.
- The server accepts that text only when it matches a strict grammar for
the same operation. Other output is dropped, as before.
- Provider refusals during the restore upload and restore command are
recorded by closed labels, like copy-back.
- A failed saved-workspace restore raises `RemoteWorkspaceRestoreError`
with the server step (`snapshot_lookup`, `snapshot_download`,
`sandbox_restore`). It is retryable only when the owner re-reads the
same writer, epoch, and saved version with no unsaved work, and the
sandbox did not name a refusal reason.
- Setup failure handling classifies a pre-dispatch legacy restore
failure as `remote_workspace_restore_failed`. A retryable failure uses
the existing bounded bootstrap retry. A non-retryable or exhausted
failure records a board-owned recovery action with the redacted cause,
when stranded-work recovery does not scan the task status (for example
`blocked`).
- Stranded-work recovery gets a named notice for this code. Generic
continuation recovery does not grant it a second budget, and legacy
reconciliation does not invent provider actions for it.
- `doc/execution-semantics.md` documents the contract.

## Verification

- Red: the new transport tests failed on the base code with
`remote_workspace_operation_failed: operation=restore exit=1` (and the
same for prepare and repository). The new completion tests failed
because the run became `setup_failed` with no retry and no recovery
action.
- Green: `npx vitest run src/services/remote-workspace-transport.test.ts
src/services/heartbeat/run-completion.test.ts
src/services/remote-workspaces.integration.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/services/legacy-execution-recovery.test.ts src/services/recovery
src/services/remote-workspace-save-concurrency.test.ts
src/services/native-runtime/native-workspace-finalizer-failure.test.ts
src/__tests__/native-workspace-finalization-work.test.ts` passes (563
tests).
- The transport tests use real sandbox commands. They prove an occupied
folder reports `stage=inspect_root reason=occupied_root` and keeps its
files. They prove a wrong manifest reports `stage=extract
archive=manifest_mismatch`. They prove a missing branch reports
`stage=checkout_branch`, and a changed origin reports
`stage=verify_origin`. Forged output with a path is dropped.
- The real-PostgreSQL cold-restore test proves an occupied root gives a
non-retryable `sandbox_restore` failure and keeps the unknown files.
- The completion tests cover retried, exhausted, refused, post-dispatch,
and `in_progress` cases.
- `tsc --noEmit` for the server reports no errors in the changed files.

## Risks

- A retry keeps the same restore path. If the cause is deterministic but
unnamed, the run uses its two retries before the board action. Named
refusals skip the retries.
- The board action is recorded only for statuses that stranded-work
recovery does not scan, and only when no other action is active. This
prevents a second recovery identity for one failure.
- Script stderr grammar is new. Old sandboxes run the script that the
server sends, so no runner update is necessary.
- No migration, deployment, or credential change.

> 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, through Claude Code with
tool use, code execution, and browser tools for read-only staging
evidence. Context window and reasoning mode are host-managed.

## 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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 03:13:44 -06:00
1 parent 833d068741
commit beb73381ca
6 files changed
+322 -13

No files matched your search

+17
View File
@@ -2154,6 +2154,23 @@ is the current review participant. Before the receipt, the hold prevents a secon
executor on the workspace. A snapshot-only save does not release the hold,
because the task workspace does not contain the saved files.
A Stop or reassignment can reach a native run while its provider session is
still opening. The startup owner then fences the late handle before any prompt,
and the Stop acknowledgement records `nativeCancellation.beforeProviderTurn`.
A reattached provider from restart recovery never gets this proof. When the run
also has no runner event outside the startup lifecycle (harness, session, workspace,
runtime phase, credential refresh), no turn or provider event, and
no native result, no provider outcome
is unknown. The completed copyback receipt then releases the hold for the stopped
run's own agent as well, so the user's redirect continues once. The hold row and
its evidence are unchanged.
A queued turn for the same task that never started is not a workspace writer for
automatic recovery. The hold gates its dispatch until the recovery receipt is
complete, so recovery and that turn cannot both wait for each other. Running
turns, and queued turns for other tasks that share the workspace, still block
recovery.
The deadline is a backstop, not the normal failure path. When native task
preparation fails after a fresh controller claim and before any provider turn,
the controller stops renewal and releases only that exact claim. Ordinary
+56 -10
View File
@@ -1,10 +1,10 @@
import { isPreDispatchReviewWait, neverStartedDispatchCancellationPredicate } from "./pre-dispatch-review-wait.js";
import { canonicalExecutionUuid } from "./execution-identity.js";
import { preDispatchExecutionWaitRecoveryActionPredicate } from "./pre-dispatch-execution-wait.js";
import { and, desc, eq, gt, inArray, not, or, sql } from "drizzle-orm";
import { and, desc, eq, gt, inArray, not, or, sql, type SQL } from "drizzle-orm";
import { conversationRecoveryActionPredicate, getConversationOwnershipBlocker } from "./conversation-continuation.js";
import { z } from "zod";
import { agentWakeupRequests, chatConversations, chatEndpoints, environmentLeases, heartbeatRuns, issueComments, issues, issueRecoveryActions, nativeRunFinalizations, toolConnections, workspaceOperations, type Db } from "@paperclipai/db";
import { agentWakeupRequests, chatConversations, chatEndpoints, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueComments, issues, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, toolConnections, workspaceOperations, type Db } from "@paperclipai/db";
import { canContinueCancelledRun, readRunCancellation } from "./run-cancellation.js";
import { canRetryStoppedRun } from "./cancelled-native-startup.js";
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
@@ -126,13 +126,58 @@ export function nativeExecutionRecoveryActionPredicate() {
and ${heartbeatRuns.runtimeMode} = 'native')`;
}
// Runner startup lifecycle families: they open no turn. Every other event the
// runner sourced counts as provider work, so a new or unlisted family keeps
// the hold (fail closed). Server-side records without a source event (spans,
// process identity, lifecycle) count only when they name turn or provider work.
export const PROVIDER_STARTUP_EVENT_PREFIXES = ["harness.", "session.", "workspace.", "runtime.phase.",
"runtime.activity.", "provider.notice.", "auth.credentials.", "mcp_app.discovered", "mcp_app.initializing", "mcp_app.ready",
"mcp_app.resource.", "mcp_app.host_context.", "mcp_app.teardown", "mcp_app.failed"] as const;
export const PROVIDER_STARTUP_EVENT_TYPES = ["run.attached", "run.detached"] as const;
const SERVER_TURN_WORK_EVENT_PREFIXES = ["turn.", "item.", "tool.", "semantic_tool.", "run.result."] as const;
const SERVER_TURN_WORK_EVENT_TYPES = ["provider.event", "provider.rpc_result"] as const;
const anyPrefix = (eventType: SQL, prefixes: readonly string[]) =>
sql.join(prefixes.map(prefix => sql`starts_with(${eventType}, ${prefix})`), sql` or `);
const anyType = (eventType: SQL, types: readonly string[]) =>
sql`${eventType} in (${sql.join(types.map(type => sql`${type}`), sql`, `)})`;
/** SQL truth for an event row (alias columns) that may prove provider turn work. */
export function providerTurnWorkEventCondition(eventType: SQL, sourceEventId: SQL) {
return sql`((${sourceEventId} is not null and not (${anyType(eventType, PROVIDER_STARTUP_EVENT_TYPES)}
or ${anyPrefix(eventType, PROVIDER_STARTUP_EVENT_PREFIXES)}))
or ${anyType(eventType, SERVER_TURN_WORK_EVENT_TYPES)} or ${anyPrefix(eventType, SERVER_TURN_WORK_EVENT_PREFIXES)})`;
}
/**
* A Stop that the native startup owner fenced before it published a provider
* handle proves that no provider turn ran. The run's own durable record must
* agree: no turn, tool or provider event and no native result. `stopped` is
* the SQL alias of the stopped run.
*/
export function stoppedBeforeProviderTurnCondition() {
return sql`(stopped.status = 'cancelled' and stopped.runtime_mode = 'native'
and stopped.result_json->'nativeCancellation'->>'schema' = 'paperclip.native-cancellation.v1'
and stopped.result_json->'nativeCancellation'->>'runId' = stopped.id::text
and stopped.result_json->'nativeCancellation'->>'scope' = 'run'
and stopped.result_json->'nativeCancellation'->>'dispatchState' = 'acknowledged'
and stopped.result_json->'nativeCancellation'->'beforeProviderTurn' = 'true'::jsonb
and not exists (select 1 from ${heartbeatRunEvents} work
where work.company_id = stopped.company_id and work.run_id = stopped.id
and ${providerTurnWorkEventCondition(sql`work.event_type`, sql`work.source_event_id`)})
and not exists (select 1 from ${nativeRunResults} result
where result.company_id = stopped.company_id and result.run_id = stopped.id))`;
}
/**
* A file-preservation hold binds a newer task owner only until automatic
* recovery saved the stopped run's files. That owner's turn is a fresh
* assignment, not a replay: the stopped run's unknown outcomes stay with its
* own agent, which keeps the hold. The proof is the durable completed-recovery
* receipt bound to this action, so later reuse of the workspace by the new
* owner (new epoch, writer or snapshot) cannot re-arm the hold.
* own agent, which keeps the hold. A Stop fenced before any provider turn has
* no unknown outcome, so its hold releases for the same agent too. The proof is
* the durable completed-recovery receipt bound to this action, so later reuse
* of the workspace by the current owner (new epoch, writer or snapshot) cannot
* re-arm the hold.
*/
export function restoreHoldReleasedForCurrentOwnerPredicate() {
const sourceRunId = sql`coalesce(${issueRecoveryActions.evidence}->>'runId', ${issueRecoveryActions.evidence}->>'sourceRunId')`;
@@ -150,11 +195,12 @@ export function restoreHoldReleasedForCurrentOwnerPredicate() {
and stopped.status in ('failed', 'timed_out', 'interrupted', 'cancelled')
and stopped.finished_at is not null
and task.assignee_agent_id is not null
and task.assignee_agent_id <> stopped.agent_id
and not coalesce(task.status = 'in_review'
and task.execution_state->>'status' = 'pending'
and task.execution_state->'currentParticipant'->>'type' = 'agent'
and task.execution_state->'currentParticipant'->>'agentId' = stopped.agent_id::text, false))`,
and ((task.assignee_agent_id <> stopped.agent_id
and not coalesce(task.status = 'in_review'
and task.execution_state->>'status' = 'pending'
and task.execution_state->'currentParticipant'->>'type' = 'agent'
and task.execution_state->'currentParticipant'->>'agentId' = stopped.agent_id::text, false))
or ${stoppedBeforeProviderTurnCondition()}))`,
completedWorkspaceRecoveryCondition(sourceRunId),
// Copyback put the files back into the task workspace that the new owner
// reuses. A snapshot-only save needs an explicit fresh-workspace request
@@ -8421,6 +8421,107 @@ describe("native session cancellation", () => {
}
});
it.each(["in-time", "late"] as const)("records that a Stop during startup (%s) preceded any provider turn", async timing => {
const root = await mkdtemp(join(tmpdir(), "native-startup-stop-proof-"));
const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR;
process.env.PAPERCLIP_RUNNER_STATE_DIR = root;
let open!: () => void, started!: () => void;
const opening = new Promise<void>(resolve => { open = resolve; });
const admitted = new Promise<void>(resolve => { started = resolve; });
const submitTurn = vi.fn();
state.cancel.mockClear();
state.execute.mockReset().mockImplementationOnce(async (options) => {
started(); await opening;
await options.onSession({ cancel: state.cancel });
submitTurn();
throw new Error("a stopped startup must not reach prompt submission");
});
const runResultJson: Record<string, unknown> = {};
const updates: Array<{ table: unknown; values: Record<string, unknown> }> = [];
const outcome = executePaperclipNativeSession({ db: leaseDb(execution, {}, runResultJson, updates), execution, runnerInstanceId: "runner" })
.catch(error => error);
const persistence = cancellationDb();
try {
await admitted;
if (timing === "in-time") {
const stopping = cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run" });
await new Promise(resolve => setImmediate(resolve));
open();
expect(await stopping).toMatchObject({ dispatched: true });
await outcome;
expect(persistence.getResultJson().nativeCancellation).toMatchObject({ dispatchState: "acknowledged", beforeProviderTurn: true });
} else {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const stopping = cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run" }).catch(error => error);
await new Promise(resolve => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(30_000);
expect(await stopping).toMatchObject({ name: "NativeCancellationPendingRecoveryError" });
vi.useRealTimers();
Object.assign(runResultJson, persistence.getResultJson());
open(); await outcome;
const acknowledgement = updates.find(update => update.table === heartbeatRuns &&
(update.values.resultJson as Record<string, any> | undefined)?.nativeCancellation?.dispatchState === "acknowledged");
expect(acknowledgement?.values.resultJson).toMatchObject({ nativeCancellation: { beforeProviderTurn: true } });
}
expect(state.cancel).toHaveBeenCalledOnce();
expect(submitTurn).not.toHaveBeenCalled();
} finally {
vi.useRealTimers(); open(); await outcome;
if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous;
await rm(root, { recursive: true, force: true });
}
});
it("records no pre-turn proof for a Stop after the provider handle was published", async () => {
const running = executePaperclipNativeSession({ db: leaseDb(), execution, runnerInstanceId: "runner" });
await vi.waitFor(() => expect(state.release).toBeTypeOf("function"));
const persistence = cancellationDb();
try {
expect(await cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run" }))
.toMatchObject({ dispatched: true });
expect(persistence.getResultJson().nativeCancellation).toMatchObject({ dispatchState: "acknowledged" });
expect(persistence.getResultJson().nativeCancellation).not.toHaveProperty("beforeProviderTurn");
} finally { state.release?.(); await running; }
});
it("records no pre-turn proof for a startup Stop under restart recovery", async () => {
// A recovered controller may reattach a provider that already worked.
const current = structuredClone(execution);
current.binding.runId = "restart-recovery-startup-stop";
const { currentNativeControllerIdentity } = await import("./native-restart-recovery.js");
const controller = await currentNativeControllerIdentity();
const db = leaseDb(current, { leaseOwner: "recovered-controller", controllerBootId: controller.bootId,
controllerPid: controller.pid, controllerGeneration: 2, attempt: 1 });
let open!: () => void, started!: () => void;
const opening = new Promise<void>(resolve => { open = resolve; });
const admitted = new Promise<void>(resolve => { started = resolve; });
const submitTurn = vi.fn();
state.cancel.mockClear();
state.execute.mockReset().mockImplementationOnce(async (options) => {
started(); await opening;
await options.onSession({ cancel: state.cancel });
submitTurn();
throw new Error("a stopped startup must not reach prompt submission");
});
const outcome = executePaperclipNativeSession({ db, execution: current, runnerInstanceId: "runner",
leaseOwner: "recovered-controller", restartRecovery: { kind: "bootstrap_incomplete" as const, runId: current.binding.runId,
leaseOwner: "recovered-controller", controllerGeneration: 2, providerAttempt: 1, restartKind: "hard" as const,
recoveryRequestId: null } }).catch(error => error);
const persistence = cancellationDb({ runId: current.binding.runId });
try {
await admitted;
const stopping = cancelNativeSession(current.binding.runId, "operator Stop", { db: persistence.db, scope: "run" });
await new Promise(resolve => setImmediate(resolve));
open();
expect(await stopping).toMatchObject({ dispatched: true });
await outcome;
expect(persistence.getResultJson().nativeCancellation).toMatchObject({ dispatchState: "acknowledged" });
expect(persistence.getResultJson().nativeCancellation).not.toHaveProperty("beforeProviderTurn");
expect(submitTurn).not.toHaveBeenCalled();
} finally { open(); await outcome; }
});
it("routes control-plane cancellation to the active normalized session and removes the handle", async () => {
const running = executePaperclipNativeSession({
db: leaseDb(),
@@ -353,6 +353,10 @@ type NativeSessionStartup = {
executionLeaseRenewal?: NativeExecutionLeaseRenewal;
/** A fresh claim whose preparation failed before any provider turn. */
unstartedClaim?: { leaseOwner: string; attempt: number };
/** A provider session handle was published; a turn may have followed it. */
sessionPublished?: boolean;
/** Restart recovery reattaches a provider that may already have worked. */
providerWorkMayPrecede?: boolean;
executionContext?: NativeExecutionContext;
settled: Promise<void>;
settle: () => void;
@@ -7739,6 +7743,8 @@ export function cancelNativeSession(
scope?: "turn" | "run" | "issue";
replacementAccepted?: boolean;
cancellationRequestId?: string;
/** Internal: the startup owner fenced this Stop before any provider turn. */
beforeProviderTurn?: boolean;
},
): Promise<{
dispatched: boolean;
@@ -7754,6 +7760,8 @@ export async function cancelNativeSession(
scope?: "turn" | "run" | "issue";
replacementAccepted?: boolean;
cancellationRequestId?: string;
/** Internal: the startup owner fenced this Stop before any provider turn. */
beforeProviderTurn?: boolean;
},
): Promise<
| boolean
@@ -7779,7 +7787,8 @@ export async function cancelNativeSession(
async function performNativeCancellation(
runId: string,
reason: string,
options?: { db: Db; scope?: "turn" | "run" | "issue"; replacementAccepted?: boolean; cancellationRequestId?: string },
options?: { db: Db; scope?: "turn" | "run" | "issue"; replacementAccepted?: boolean; cancellationRequestId?: string;
beforeProviderTurn?: boolean },
) {
const runStop = (options?.scope ?? "run") === "run";
let decision: NativeStatusDecision | null = null;
@@ -8098,6 +8107,11 @@ async function performNativeCancellation(
const startup = runStop && !activeNativeSessions.has(runId) ? nativeSessionStartups.get(runId) : undefined;
let settleStartupCancellation: (() => void) | undefined;
let settleActiveCancellation: (() => void) | undefined;
// Proof for the acknowledgement: this Stop reached startup before it
// published a provider handle. Publication then fences that handle, so no
// provider turn can follow. A reattached provider may already have worked.
const beforeProviderTurn = runStop && (options?.beforeProviderTurn === true || Boolean(startup
&& startup.sessionPublished !== true && startup.providerWorkMayPrecede !== true));
if (startup) {
startup.stopRequested = true;
startup.executionContext?.abort(new Error("native_session_cancelled"));
@@ -8298,6 +8312,7 @@ async function performNativeCancellation(
decisionId,
acknowledgementAuditId,
acknowledgedAt: new Date().toISOString(),
...(beforeProviderTurn ? { beforeProviderTurn: true } : {}),
},
},
updatedAt: new Date(),
@@ -8479,6 +8494,7 @@ export async function executePaperclipNativeSession(input: {
let settle!: () => void;
const startup: NativeSessionStartup = {
priorRestartDetach: nativeRunsDetachingForRestart.get(runId),
providerWorkMayPrecede: Boolean(input.restartRecovery),
settled: new Promise<void>(resolve => { settle = resolve; }),
settle: () => settle(),
promise: new Promise<ActiveNativeSession | null>(resolve => { resolveStartup = resolve; }),
@@ -10340,6 +10356,9 @@ async function executePaperclipNativeSessionWithinScope(
const active: ActiveNativeSession = { session, cancelRequested: false };
activeNativeSessions.set(input.execution.binding.runId, active);
const startup = nativeSessionStartups.get(input.execution.binding.runId);
const stopBeforeProviderTurn = startup?.stopRequested === true && startup.sessionPublished !== true
&& startup.providerWorkMayPrecede !== true;
if (startup) startup.sessionPublished = true;
startup?.resolve(active);
if (startup?.stopRequested) {
// Publication wakes the Stop caller; wait for its durable ACK
@@ -10358,7 +10377,8 @@ async function executePaperclipNativeSessionWithinScope(
)).limit(1);
const pendingStop = record(record(stopRun?.resultJson).nativeCancellation);
if (pendingStop.scope === "run" && pendingStop.dispatchState === "pending") {
await cancelNativeSession(input.execution.binding.runId, "Stop requested during native startup", { db: input.db, scope: "run" });
await cancelNativeSession(input.execution.binding.runId, "Stop requested during native startup",
{ db: input.db, scope: "run", beforeProviderTurn: stopBeforeProviderTurn });
}
} catch (error) {
// Best effort: the in-memory Stop below still fences this
@@ -7,7 +7,8 @@ import { readNativeWorkspaceSyncReference } from "./native-runtime/native-worksp
import { SystemRecoveryFailure } from "./system-recovery-state.js";
import type { NativeWorkspaceFinalizationOwnership } from "./native-runtime/native-workspace-finalization-ownership.js";
import { readStopOnlyCleanup } from "./sandbox-stop-and-retain.js";
import { ENVIRONMENT_LEASE_POLICIES, readRemoteWorkspaceAuthority, remoteWorkspaceRunReferenceSchema, type EnvironmentLeasePolicy,
import { LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
import { ENVIRONMENT_LEASE_POLICIES, EXECUTION_RECONCILIATION_CAUSES, readRemoteWorkspaceAuthority, remoteWorkspaceRunReferenceSchema, type EnvironmentLeasePolicy,
type WorkspaceSourceVerificationFailure } from "@paperclipai/shared";
export const workspaceExportCauses = ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"];
@@ -81,6 +82,14 @@ export async function inspectWorkspaceRecoverySource(db: Db, input: WorkspaceRec
if (!run) throw invalid("source_run_missing");
if (!action) throw invalid("recovery_action_missing");
if (action.evidence.runId !== run.id) throw invalid("recovery_action_run_mismatch");
// This file-preservation hold blocks every dispatch for the task until its
// recovery receipt completes (see getExecutionBlocker).
const restoreRecovery = action.evidence.workspaceRestoreRecovery as { schema?: unknown } | undefined;
const automaticRecovery = action.evidence.automaticRecovery as { replay?: unknown } | undefined;
const holdGatesTaskDispatch = restoreRecovery?.schema === LEGACY_WORKSPACE_RECOVERY_SCHEMA
&& action.evidence.workspaceRestoreFailure !== "restore_unsafe_archive"
&& (EXECUTION_RECONCILIATION_CAUSES as readonly string[]).includes(action.cause)
&& (["active", "escalated"].includes(action.status) || automaticRecovery?.replay === "blocked");
if (!terminalWorkspaceRunStates.includes(run.status)) throw invalid("source_run_not_terminal");
if ((run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId) !== input.issueId) throw invalid("source_issue_mismatch");
const rawReference = run.runnerProfileJson?.nativeWorkspaceSync;
@@ -208,6 +217,12 @@ export async function inspectWorkspaceRecoverySource(db: Db, input: WorkspaceRec
and a.evidence->>'runId' = ${run.id}
and a.evidence#>>'{explicitUserContinuation,runId}' = ${heartbeatRuns.id}::text
and a.evidence#>>'{explicitUserContinuation,readiness,mode}' = 'fresh_isolated_workspace')`,
// A never-started queued turn for this task cannot write: this hold
// gates its dispatch until recovery completes. Counting it as a writer
// would leave both waiting for each other.
holdGatesTaskDispatch ? sql`not coalesce(${heartbeatRuns.status} = 'queued' and ${heartbeatRuns.startedAt} is null
and ${heartbeatRuns.processPid} is null and ${heartbeatRuns.processGroupId} is null
and ${heartbeatRuns.contextSnapshot}->>'issueId' = ${input.issueId}, false)` : undefined,
or(eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.issueId, input.issueId),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${input.issueId}`,
sql`${heartbeatRuns.contextSnapshot}->>'executionWorkspaceId' = ${descriptor.workspaceId}`))).limit(1);
@@ -3619,6 +3619,116 @@ describe("automatic retained workspace recovery", () => {
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId, workspaceRepairRequired: false });
}, 30_000);
// Shape of a Stop or reassignment that reached a remote native run while its
// provider session was still opening: the startup owner fenced the handle, the
// run settled cancelled with a file-preservation hold, and the follow-up turn
// (the redirect comment, or the new owner's wake) was already queued.
async function stopBeforeSessionOpen(f: Awaited<ReturnType<typeof seed>>, options: { errorCode: string; proof?: boolean }) {
const nativeCancellation = { schema: "paperclip.native-cancellation.v1", runId: f.runId, companyId: f.companyId,
issueId: f.issueId, scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true,
intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID(), acknowledgedAt: new Date().toISOString(),
...(options.proof === false ? {} : { beforeProviderTurn: true }) };
const stopped = await run(f.runId);
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: options.errorCode, finishedAt: new Date(),
resultJson: { ...stopped.resultJson, nativeCancellation, startupCancellation: { errorCode: options.errorCode, beforeNativeSelection: false } },
}).where(eq(heartbeatRuns.id, f.runId));
const held = await action(f.actionId);
await db.update(issueRecoveryActions).set({ status: "resolved", outcome: "blocked", resolvedAt: new Date(),
fingerprint: `native-workspace:${f.runId}`, evidence: { ...held.evidence, continuationDelivery: "invalidated",
automaticRecovery: { policy: "preserve_without_replay_v1", runId: f.runId, replay: "blocked", actionOutcome: "unknown" } },
}).where(eq(issueRecoveryActions.id, f.actionId));
await db.update(issues).set({ status: "in_progress" }).where(eq(issues.id, f.issueId));
}
const queueFollowUp = async (f: Awaited<ReturnType<typeof seed>>, agentId: string) =>
(await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId, runtimeMode: "legacy", status: "queued",
invocationSource: "on_demand", contextSnapshot: { issueId: f.issueId, wakeReason: "issue_commented" } }).returning())[0]!;
const pendingTurns = (f: Awaited<ReturnType<typeof seed>>) => db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, f.companyId), inArray(heartbeatRuns.status, ["queued", "scheduled_retry", "running"])));
it("continues the same agent's redirect once after a Stop that landed before the provider session opened", async () => {
const f = await seed();
const agentId = (await run(f.runId)).agentId;
await stopBeforeSessionOpen(f, { errorCode: "cancelled" });
// Startup lifecycle from the runner opens no turn and is not provider work.
for (const [seq, eventType] of ["harness.starting", "harness.ready", "workspace.ready", "runtime.phase.changed",
"session.starting", "session.started", "session.updated", "auth.credentials.updated"].entries()) await db.insert(heartbeatRunEvents).values({
companyId: f.companyId, runId: f.runId, agentId, seq: 9_000 + seq, eventType, stream: "system", level: "info",
message: eventType, sourceEventId: randomUUID() });
const followUp = await queueFollowUp(f, agentId);
// No executor may touch the workspace while its files are unrecovered.
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId, workspaceRepairRequired: true });
// The queued follow-up never started and is gated by this hold, so it is not
// a workspace writer. Recovery must not wait for it.
await recoverRetainedWorkspace(f.input);
expect(readSystemRecovery((await action(f.actionId)).evidence)).toMatchObject({ state: "complete" });
expect(await hasVerifiedWorkspaceRepair(db, await run(f.runId))).toBe(true);
// No provider turn ran, so no outcome is unknown: the same agent continues.
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
// Exactly once: the queued follow-up is the only turn, and a sweep adds none.
await heartbeatService(db).reconcileStrandedAssignedIssues({ companyId: f.companyId, issueId: f.issueId });
expect((await pendingTurns(f)).map(item => item.id)).toEqual([followUp.id]);
// The audit row keeps its no-replay evidence.
expect(await action(f.actionId)).toMatchObject({ status: "resolved", outcome: "blocked",
evidence: { automaticRecovery: { replay: "blocked" } } });
}, 30_000);
it("hands a task reassigned before the provider session opened to its new owner once", async () => {
const f = await seed();
await stopBeforeSessionOpen(f, { errorCode: "issue_reassigned" });
const newOwnerId = randomUUID();
await db.insert(agents).values({ id: newOwnerId, companyId: f.companyId, name: "Reviewer", adapterType: "paperclip_runner",
status: "idle", runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } } });
await db.update(issues).set({ assigneeAgentId: newOwnerId }).where(eq(issues.id, f.issueId));
const handoff = await queueFollowUp(f, newOwnerId);
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ runId: f.runId, workspaceRepairRequired: true });
await recoverRetainedWorkspace(f.input);
expect(readSystemRecovery((await action(f.actionId)).evidence)).toMatchObject({ state: "complete" });
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
await heartbeatService(db).reconcileStrandedAssignedIssues({ companyId: f.companyId, issueId: f.issueId });
expect((await pendingTurns(f)).map(item => item.id)).toEqual([handoff.id]);
}, 30_000);
it.each(["no-proof", "turn-event", "provider-event", "unlisted-provider-event", "running-writer", "other-task-writer",
"unscoped-queued-writer", "non-gating-hold"] as const)
("keeps the stopped agent held when provider work cannot be excluded (%s)", async kind => {
const f = await seed();
const agentId = (await run(f.runId)).agentId;
await stopBeforeSessionOpen(f, { errorCode: "cancelled", proof: kind !== "no-proof" });
if (kind === "turn-event") await appendHeartbeatRunEvent(db, { companyId: f.companyId, runId: f.runId, agentId,
eventType: "turn.started", stream: "system", level: "info", message: "turn started" });
if (kind === "provider-event") await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, runId: f.runId, agentId,
seq: 9_999, eventType: "item.completed", stream: "system", level: "info", message: "provider item", sourceEventId: randomUUID() });
// A runner family outside the startup lifecycle counts as work even when unlisted.
if (kind === "unlisted-provider-event") await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, runId: f.runId,
agentId, seq: 9_999, eventType: "delegation.completed", stream: "system", level: "info", message: "delegated",
sourceEventId: randomUUID() });
if (kind === "running-writer" || kind === "other-task-writer" || kind === "unscoped-queued-writer") {
// A started turn, or a queued one that this task's dispatch gate does not cover, is a real writer.
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId, runtimeMode: "legacy",
status: kind === "running-writer" ? "running" : "queued",
...(kind === "unscoped-queued-writer" ? { issueId: f.issueId } : {}),
contextSnapshot: kind === "running-writer" ? { issueId: f.issueId }
: kind === "other-task-writer" ? { issueId: randomUUID(), executionWorkspaceId: f.issueId } : {} });
await expect(recoverySources.inspectWorkspaceRecoverySource(db, f)).rejects.toMatchObject({ code: "workspace_writer_active" });
return;
}
if (kind === "non-gating-hold") {
// A resolved action without a no-replay hold does not gate dispatch, so a
// queued turn for the task can start and must still count as a writer.
const held = await action(f.actionId);
await db.update(issueRecoveryActions).set({ evidence: { ...held.evidence,
automaticRecovery: { policy: "preserve_without_replay_v1", runId: f.runId, replay: "allowed" } } })
.where(eq(issueRecoveryActions.id, f.actionId));
await queueFollowUp(f, agentId);
await expect(recoverySources.inspectWorkspaceRecoverySource(db, f)).rejects.toMatchObject({ code: "workspace_writer_active" });
return;
}
await queueFollowUp(f, agentId);
await recoverRetainedWorkspace(f.input);
expect(await hasVerifiedWorkspaceRepair(db, await run(f.runId))).toBe(true);
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ recoveryActionId: f.actionId, runId: f.runId });
}, 30_000);
it("keeps a snapshot-only recovery held for a new assignee because the task workspace lacks the saved files", async () => {
const f = await seed("native", false);
await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned" }).where(eq(heartbeatRuns.id, f.runId));