Files
PaperClipAI/server/src/services/native-runtime/native-environment-settlement-work.ts
T
DottaandPaperclip 669538534c fix(recovery): preserve failed remote reattach workspaces (#18936)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Native runs can edit files in a remote execution workspace.
> - A failed reattach can leave an observed coordinator and unpublished
files.
> - The failure handler recognizes the older workspace-sync descriptor
but misses the modern workspace reference.
> - The released task lock then prevents ordinary session recovery from
claiming that old run.
> - This pull request retains the files through the existing Stop,
cleanup and workspace recovery owners.
> - Exact ownership and Stop evidence permit file preservation while
unknown provider effects remain under manual reconciliation.

## Linked Issues or Issue Description

Related: #18877 protects native manual admission holds. #18879 handles
scheduled native retry lock reclaim. #18918 fixes remote workspace
row-lock order. Those changes do not record the missing modern
failure-retention obligation.

**What happened?**

A native reattach failure can leave a failed run, a dirty modern remote
workspace, an expired observed coordinator and a free task execution
lock. The run has no accepted result. The failure handler skips file
retention because it checks only the older descriptor. Recovery cannot
export while the old coordinator remains owned. A mismatched workspace
generation can also make orphan cleanup miss the existing file claim.

**Expected behavior**

Retain the exact original allocation and files. Stop the allocation
through the existing lifecycle owner before retiring the stale
coordinator. Keep checkpoints and unknown external effects available for
manual reconciliation. Unsafe or changed source authority must prevent
physical cleanup.

**Steps to reproduce**

1. Prepare a modern remote workspace for a native run.
2. Record a provider checkpoint and an active interruption incident.
3. Fail reattach preparation and release the task execution lock.
4. Let the coordinator lease expire.
5. Run the existing orphan, environment cleanup and workspace recovery
workers.
6. Verify the original files from a cold archive read and verify that no
new native session starts.

## What Changed

- Include modern prepared references in the existing heartbeat
failure-retention producer.
- Add a file-only historical intake for an exact failed observed run in
the normal expired reattach state.
- Require an untouched active ephemeral lease, a free blocked task,
matching workspace writer and generation, no accepted result and
complete single-owner allocation history.
- Persist the existing StopAndRetain intent before provider I/O. Use
nonblocking row locks and classify busy ownership only after rollback.
- Use the existing native environment settlement owner after exact Stop
and retention receipts arrive. Keep its single scoped discovery query.
- Preserve source and coordinator fingerprints across the handoff. Keep
original checkpoints, errors and manual unknown-effect incidents.
- Keep stale or malformed modern workspace claims held when preservation
intake refuses them.
- Add actual PostgreSQL, heartbeat, allocator, worker restart, archive
and safety controls. Document the narrow recovery contract.

## Verification

Current published head `b1f49691187a398415cf9b16b723e3ab473f0f91`: all
required CI checks pass (54 success, 3 skipped, 1 neutral); Greptile is
5/5 with no open threads. The separate Paperclip assessment is queued
and its current response says work is paused. That assessment is
unavailable; no additional review nudge or hosted retry was issued.

All local commands use Node 24.22.2 on macOS arm64.

- Exact published head: `b1f49691187a398415cf9b16b723e3ab473f0f91`.
Required CI passes (54 success, 3 skipped, 1 neutral); Greptile is 5/5
with zero open threads. The optional Paperclip assessment remains
queued/paused without a completed score.
- Final recursive `pnpm -r typecheck`: passed.
- Final `pnpm build`: passed.
- Final three native producer, containment and scheduling files: **67/67
passed**. These include the real coordinator and worker restart, the
production heartbeat recovery callback, exact Stop receipts, cold
archive restore, and zero native backend, new allocation or destructive
release calls.
- The plugin allocator cases use the actual selector with `reuseLease:
true`. Active and released ephemeral leases receive a fresh allocation.
- The assembled reattach producer checks that the task and coordinator
claim commit before physical resume. A historical pin refuses that
current owner. A later claim behind a committed pin remains held.
- The safety controls cover reusable allocations, manual and unknown
states, cleanup in flight, accepted but unbound results, late allocation
owners, wrong or malformed workspace authority, changed
generation/checkpoint, lost Stop acknowledgement and a real
cleanup-producer lock conflict.
- Untouched base `e7549ab6131555f655736e6b544892a3b1e323cb`: four
expected behavioral failures and one passing legacy-descriptor control.
The failures cover the modern producer, original provider history,
historical retention and wrong-epoch cleanup. Fixed production files
were restored byte-for-byte after this comparison.
- The first affected six-file run completed **562 passed / 1 failed**.
The failure was an existing HTTP case with `socket hang up`. That exact
case passes separately on both untouched base and this branch. The
original connection failure remains unexplained. At `0e92de8c`, the
complete workspace family passes **188/188** with unchanged assertions.
The environment cleanup owner family also passes all 23 cases. The later
guard changes are qualified by the complete 67-case native owner group.
A full repository test run was not repeated locally; the requested
full-ci workflow runs the complete repository suites.
- Published `0e92de8c` fails both new explicit-null Stop/export marker
controls because it creates a new retention pin. The follow-up uses the
shared presence-aware Stop guard. Both controls now preserve the marker
and record no new retention pin, Stop or destructive release.
- Paperclip's review found that malformed workspace authority could
throw and abort an orphan page. Published `5f6cd4f5` fails all three new
controls: intake, the actual page and after-Stop settlement. The same
strict schema now uses a safe parse, and the existing OBSERVED hold runs
before the prepared-source lookup. The malformed source stays held while
the next valid source receives its retention pin.
- Two existing scheduling tests caught the draft's second global
discovery query. The final single-query repair passes all 36 unchanged
scheduling tests.
- An earlier restarted archive assertion stopped after 20 seconds. Its
recorded Stop was valid, and the coordinator had settled. The existing
evidence recheck deadline was still 13 seconds ahead. The final test
observes the unchanged 24–36 second policy within its existing 60 second
case limit.

The external fake provider Stop and file transport are controlled test
boundaries. Database ownership, lifecycle workers, archive bytes,
encrypted local storage and cold restore are real. This does not claim a
live cloud sandbox, S3 or model run. No hosted task was changed for this
PR.

## Risks

- Historical intake is deliberately narrow. Reusable, already released,
mismatched or competing allocations remain held.
- File preservation does not settle unknown provider effects or
authorize a repeated provider turn. The task can still require manual
reconciliation.
- A busy or changed owner delays preservation. The original files and
claims remain retained.
- This does not repair the underlying sandbox readiness failure or
establish the cause of a separate archive-capture failure.
- Deployment and recovery of original hosted tasks remain unverified.

## Model Used

OpenAI Codex, GPT-6 family, with repository inspection, code execution
and GitHub tools. The runtime does not expose the exact serving model ID
or context window size. A separate Codex agent and the coordinator
performed read-only source review.

## 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-04 07:19:03 -06:00

138 lines
8.8 KiB
TypeScript

import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
import { environmentLeases, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import type { ProviderResourceDisposition } from "../environment-runtime.js";
import { nextRunLeaseReleaseAt, pendingRunLeaseReleaseCondition } from "../environment-lease-release-state.js";
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import type { NativeEnvironmentSettlementScope } from "../native-environment-settlement-notifications.js";
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
import { hasRemoteTerminationReceipt } from "../remote-execution-termination.js";
import { hasConfirmedSandboxStopAndRetain } from "../sandbox-stop-and-retain.js";
import { settleStoppedObservedWorkspace } from "./native-observed-workspace-preservation.js";
type Run = Pick<typeof heartbeatRuns.$inferSelect, "runnerProfileJson" | "resultJson">;
export function nativeEnvironmentSettlementDisposition(run: Run, succeeded: boolean): ProviderResourceDisposition {
const reference = readNativeWorkspaceSyncReference(run.runnerProfileJson?.nativeWorkspaceSync);
const saved = run.runnerProfileJson?.nativeResourceDisposition;
const disposition = reference?.resourceDisposition
?? (saved === "keep_running" || saved === "stop_and_retain" || saved === "destroy" ? saved : "stop_and_retain");
return succeeded && (!run.resultJson?.workspaceExportRetry || disposition === "destroy")
? disposition : "stop_and_retain";
}
type Entry = { id: string; companyId: string; issueId: string; dueAt: number };
type Settlement = { runId: string; companyId: string; agentId: string; succeeded: boolean };
export function nativeEnvironmentSettlementWork(db: Db, settle: (input: Settlement) => Promise<void>) {
const retryFloors = new Map<string, Entry>();
const matches = (item: Entry, scope?: NativeEnvironmentSettlementScope) => !scope || item.companyId === scope.companyId
&& (!scope.issueId || item.issueId === scope.issueId) && (!scope.runId || item.id === scope.runId);
function candidates(scope?: NativeEnvironmentSettlementScope, after?: string) {
return db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
issueId: nativeRunFinalizations.issueId, phase: nativeRunFinalizations.phase,
runnerProfileJson: sql<Run["runnerProfileJson"]>`case when ${heartbeatRuns.runnerProfileJson} is null then null
else jsonb_build_object('nativeWorkspaceSync', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync',
'nativeResourceDisposition', ${heartbeatRuns.runnerProfileJson}->'nativeResourceDisposition') end`,
resultJson: sql<Run["resultJson"]>`case when ${heartbeatRuns.resultJson} is null then null
else jsonb_build_object('workspaceExportRetry', ${heartbeatRuns.resultJson}->'workspaceExportRetry') end`,
}).from(nativeRunFinalizations)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, nativeRunFinalizations.runId),
eq(heartbeatRuns.companyId, nativeRunFinalizations.companyId), eq(heartbeatRuns.nativeIssueId, nativeRunFinalizations.issueId)))
.leftJoin(nativeRunResults, and(eq(nativeRunResults.id, nativeRunFinalizations.resultId),
eq(nativeRunResults.runId, nativeRunFinalizations.runId), eq(nativeRunResults.companyId, nativeRunFinalizations.companyId),
eq(nativeRunResults.issueId, nativeRunFinalizations.issueId), eq(nativeRunResults.schemaStatus, "accepted")))
.where(and(eq(heartbeatRuns.runtimeMode, "native"),
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
or(and(isNotNull(nativeRunResults.id),
or(eq(nativeRunFinalizations.phase, "committed"),
and(eq(nativeRunFinalizations.phase, "terminal_failure"),
sql`${nativeRunFinalizations.failureDetail} ? 'workspaceFinalizeAttempt'`)),
exists(db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
eq(environmentLeases.companyId, heartbeatRuns.companyId), eq(environmentLeases.heartbeatRunId, heartbeatRuns.id),
pendingRunLeaseReleaseCondition(true))))),
and(eq(heartbeatRuns.status, "failed"), eq(nativeRunFinalizations.phase, "observed"),
isNull(nativeRunFinalizations.resultId), isNotNull(nativeRunFinalizations.leaseOwner),
lte(nativeRunFinalizations.leaseExpiresAt, new Date()),
sql`${heartbeatRuns.runnerProfileJson}#>>'{remoteWorkspace,schema}' = 'paperclip.remote-workspace-run.v1'`,
sql`${heartbeatRuns.runnerProfileJson}#>>'{remoteWorkspace,state}' = 'prepared'`)),
scope ? eq(heartbeatRuns.companyId, scope.companyId) : undefined,
scope?.issueId ? eq(nativeRunFinalizations.issueId, scope.issueId) : undefined,
scope?.runId ? eq(heartbeatRuns.id, scope.runId) : undefined,
after ? gt(heartbeatRuns.id, after) : undefined,
)).orderBy(asc(heartbeatRuns.id)).limit(100);
}
async function entry(row: Awaited<ReturnType<typeof candidates>>[number]): Promise<Entry | null> {
if (row.phase === "observed") {
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, row.companyId), eq(environmentLeases.heartbeatRunId, row.id)));
return leases.length === 1 && leases.every(lease => hasRemoteTerminationReceipt(lease) && hasConfirmedSandboxStopAndRetain(lease))
? { id: row.id, companyId: row.companyId, issueId: row.issueId, dueAt: 0 } : null;
}
const dueAt = await nextRunLeaseReleaseAt(db, row.companyId, row.id,
nativeEnvironmentSettlementDisposition(row, row.phase === "committed"));
if (dueAt === null) { retryFloors.delete(row.id); return null; }
return { id: row.id, companyId: row.companyId, issueId: row.issueId,
dueAt: Math.max(dueAt, retryFloors.get(row.id)?.dueAt ?? 0) };
}
async function load(scope?: NativeEnvironmentSettlementScope, signal?: AbortSignal, canRun = () => true): Promise<Entry[]> {
const entries: Entry[] = [];
let after: string | undefined;
while (!signal?.aborted && canRun()) {
const rows = await candidates(scope, after);
for (const row of rows) {
if (signal?.aborted || !canRun()) throw new Error("Native environment discovery stopped");
const pending = await entry(row);
if (pending) entries.push(pending);
}
if (rows.length < 100) {
const retained = new Set(entries.map(item => item.id));
for (const item of retryFloors.values()) if (matches(item, scope) && !retained.has(item.id)) retryFloors.delete(item.id);
return entries;
}
after = rows[rows.length - 1].id;
}
throw new Error("Native environment discovery stopped");
}
async function check(current: Entry, signal?: AbortSignal): Promise<Entry | null> {
if (signal?.aborted) throw new Error("Native environment settlement stopped");
const scope = { companyId: current.companyId, issueId: current.issueId, runId: current.id };
const [row] = await candidates(scope);
if (!row) { retryFloors.delete(current.id); return null; }
if (row.phase === "observed") {
await settleStoppedObservedWorkspace(db, current.companyId, current.id);
return null;
}
const pending = await entry(row);
if (!pending || pending.dueAt > Date.now()) return pending;
if (signal?.aborted) throw new Error("Native environment settlement stopped");
await settle({ runId: row.id, companyId: row.companyId, agentId: row.agentId, succeeded: row.phase === "committed" });
const [remaining] = await candidates(scope);
const next = remaining ? await entry(remaining) : null;
if (!next) { retryFloors.delete(current.id); return null; }
const floor = Date.now() + 30_000;
retryFloors.set(current.id, { ...next, dueAt: floor });
return { ...next, dueAt: Math.max(next.dueAt, floor) };
}
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean,
canRecover: () => Promise<boolean> = async () => true) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.nativeEnvironmentSettlement, batchSize: 1,
decodeScope: (hint: string): NativeEnvironmentSettlementScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches,
load: async (scope, signal) => await canRecover() ? load(scope, signal, canRun) : [],
check: async (item, signal) => {
if (!canRun()) throw new Error("Native environment settlement paused");
return await canRecover() ? check(item, signal) : null;
},
}, canRun);
}
return { load, check, register };
}