Files
PaperClipAI/server/src/services/native-runtime/preserved-native-result-finalization.ts
T
c2cc69d1e4 fix(recovery): resume and settle preserved native results (#19034)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native execution can save an accepted result before workspace export
fails.
> - Recover must preserve the files and settle that same result without
another agent turn.
> - The export failure can own the task's Blocked transition and its
recovery notice.
> - Recovery must prove preservation, stop, source identity and current
authority before settlement.
> - Recover can also transfer repair ownership to the system, which must
keep explicit board export repair available.
> - This change restores those paths while preserving later task state,
live owners and unknown action outcomes.

## Linked Issues or Issue Description

Related #17916 protects unfinished exports. Related #19004 preserves
deliberate Retry authority. This change repairs accepted-result
finalization and its recovery notice.

**What happened?**

A native run accepted its result, then exhausted workspace export.
Normal Recover could save the files but leave the result and old
recovery notice unsettled. A saved Retry could also use a different
generation from preservation. Separately, Recover changed the action
owner from board to system. Explicit export Retry then rejected that
same source as stale, even when its accepted result and stopped
allocation still matched.

**Expected behavior**

Preserve files, finish the original accepted result and close only its
obsolete notice after exact proof. Keep explicit board export repair
available after system adoption. Preserve current authority, Stop, later
task changes, newer execution and every physical ownership fence.

**Steps to reproduce**

1. Accept a native result and exhaust its workspace export.
2. Request ordinary Recover, optionally with an existing saved Retry.
3. Let file preservation complete. Inspect the task and source recovery
notice.
4. To reproduce the explicit-repair defect, call export Retry after
Recover adopts the action. Before this repair it rejects the valid
system-owned source before any provider call.

## What Changed

- Record the exact accepted result, contract, task status version and
prior decision when export exhaustion creates Blocked.
- Bind passive finalization to complete, source-bound preservation and
the current chat/review authority. Recheck the proof under status commit
locks.
- Keep the saved Retry identity separate from its preservation
generation. Retain coalescing and reject malformed or mismatched
history.
- Preserve an existing published GitHub assessment for the
no-publication status-unproven wait. Keep all semantic, result,
authority and publication checks.
- Close an old export notice only after its own accepted result, export
or preservation receipts, effects, projection and old Runner ownership
settle. Keep newer work and files unchanged.
- Permit explicit board export Retry after Recover transfers the exact
source to the system. Require valid system and workspace recovery
records for that same run, plus every existing result, stop, allocation,
task and shared physical-lock check. Reject missing proof, another
source and agent ownership before provider access.
- Merge current master while retaining the stronger Stop, source and
verified preservation checks.
- Update the execution contract. No schema, endpoint, Runner protocol,
model, trust or provider artifact change.

## Verification

- Current head: `9be5a73489801095c9f39c9bfcb285a22b055912`.
- The explicit-repair regression fails before the new guard change:
ordinary Recover adopts the real database action, then export Retry
rejects it as stale. It passes after the repair.
- On the merged source, three owning PostgreSQL/file suites pass **220
tests**: `preserved-native-response-wait.integration.test.ts`,
`native-workspace-export-retry.test.ts` and
`idle-completed-history.test.ts`. The export suite includes four
unrelated-owner/source rejection controls and both board-owned and
system-adopted active-exporter controls. Results and run counts prove
that repair creates no provider turn.
- Server layered typecheck, module boundaries, `git diff --check`, and
the added-line secret/private-incident scan pass.
- Full workspace typecheck (`pnpm -r typecheck`) and build (`pnpm
build`) pass. The complete local `pnpm test:run` is still running; its
completed serialized chat partitions pass. Current-head CI is green (53
successful checks, 3 expected skips). Greptile reviewed
`9be5a73489801095c9f39c9bfcb285a22b055912` at 2026-11-05 00:58 UTC with
confidence **5/5**; all review threads are resolved.
- Original qualification remains supporting evidence: the
accepted-result fixture exercises HTTP Recover, real files, the
finalizer, saved Retry coalescing, eight commit races, current
principal/resource/head changes, Stop, later task changes, notice
closure and repeated delivery. No accepted response, provider turn or
publication is replayed.
- Earlier failures remain recorded. Original red controls exposed
wrong-action preservation, unrelated finalization barriers, generation
mismatch and a lost GitHub assessment; the original repair added
controls for them. The prior CI run
[41908572248](https://github.com/paperclipai/paperclip/actions/runs/41908572248)
failed an unchanged completed-history runtime fixture because its
loopback service refused connections through the original ten-second
readiness window. That owning suite passes unchanged in the 220-case run
above. The earlier readiness failure's cause remains unproven; no wait
or assertion was weakened.
- Local and CI tests do not establish hosted task recovery. The serving
revision and original-task outcomes require separate verification after
rollout.

## Risks

- Historical failures without the exact export-owned Blocked receipt
keep their current task status. A saved result does not grant execution
or publication authority.
- Explicit export repair still resumes only the same retained allocation
under the shared physical lock. It cannot take over an active exporter,
create another agent turn, choose a replacement sandbox or discard saved
files.
- Retiring a source notice does not release physical resources or modify
a newer task owner, later Done state or saved snapshot history.
- The proof adds bounded reads only for concrete recovery obligations.
It adds no recurring worker or polling timer.

## Model Used

OpenAI GPT-6 through Codex for the original repair. OpenAI GPT-6.1 Sol
(`gpt-6.1-sol`), xhigh reasoning, for the merge, integration
verification and system-adopted export repair. Both used repository
tools, code execution and PostgreSQL tests. The runtime does not expose
a context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used
- [x] I have checked ROADMAP.md and searched for duplicate or related
work
- [x] I have described the issue and linked related public PRs
- [x] I have included no internal task identifiers or private incident
links
- [x] I have added behavioral and safety regression tests
- [x] I have documented verification limits and risks
- [x] I have updated the execution contract
- [x] All Paperclip CI gates are green on the current head
- [x] Greptile is 5/5 on the current head with no unresolved comments
- [x] I will address review comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-11-04 17:16:17 -08:00

136 lines
11 KiB
TypeScript

import { and, eq, gte, inArray, ne, or, sql } from "drizzle-orm";
import { completionContracts, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations,
nativeRunResults, type Db } from "@paperclipai/db";
import { z } from "zod";
import { issueTreeControlService } from "../issue-tree-control.js";
import { remoteExecutionHasStopped } from "../remote-execution-termination.js";
import { readSystemRecovery } from "../system-recovery-state.js";
import { completedWorkspaceRecoveryCondition, readWorkspaceRecovery, verifiedWorkspaceRepairReceipt } from "../workspace-recovery-receipt.js";
import { nativeSha256 } from "./canonical.js";
export const nativeWorkspaceFinalizationBlockSchema = z.object({
schema: z.literal("paperclip.native-workspace-finalization-block.v1"),
companyId: z.uuid(), issueId: z.uuid(), runId: z.uuid(), agentId: z.uuid(),
resultId: z.uuid(), resultSha256: z.string().min(1),
contractId: z.uuid(), contractSha256: z.string().min(1),
identityContextId: z.uuid().nullable(), responsibleUserId: z.string().nullable(),
statusVersion: z.number().int().nonnegative(), priorDecisionId: z.uuid().nullable(),
}).strict();
export type PreservedNativeResultFinalization = {
companyId: string; issueId: string; runId: string; agentId: string;
actionId: string; generationId: string; resultId: string; resultSha256: string;
contractId: string; contractSha256: string; statusVersion: number;
identityContextId: string | null; responsibleUserId: string | null;
priorDecisionId: string | null; statusRestoration: "owned" | "unproven";
fingerprint: string;
};
export function preservedResultHasStop(result: Record<string, unknown> | null) {
if (!result) return false;
const cancellation = result.cancellation as { source?: unknown } | undefined;
return result.nativeCancellation != null || typeof result.cancelledByUserId === "string"
|| ["user", "board", "agent"].includes(String(result.cancelledByActorType ?? result.interruptedByActorType))
|| result.reassignmentStopRequested === true || ["operator", "queued_message"].includes(String(cancellation?.source));
}
export async function readPreservedNativeResultFinalization(db: Db, binding: {
companyId: string; issueId: string; runId: string; agentId: string;
}, lockProof = false): Promise<PreservedNativeResultFinalization | null> {
const query = db.select({
issue: { status: issues.status, statusVersion: issues.statusVersion, decisionId: issues.lastStatusDecisionId,
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId,
executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId },
run: { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, nativeIssueId: heartbeatRuns.nativeIssueId,
issueId: heartbeatRuns.issueId, status: heartbeatRuns.status, createdAt: heartbeatRuns.createdAt,
identityContextId: heartbeatRuns.activeIdentityContextId, responsibleUserId: heartbeatRuns.responsibleUserId,
contractSha256: heartbeatRuns.completionContractSha256,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId')`,
resultJson: sql<Record<string, unknown>>`jsonb_build_object(
'nativeCancellation', ${heartbeatRuns.resultJson}->'nativeCancellation',
'executionCancellation', ${heartbeatRuns.resultJson}->'executionCancellation',
'cancellation', ${heartbeatRuns.resultJson}->'cancellation',
'cancelledByUserId', ${heartbeatRuns.resultJson}->'cancelledByUserId',
'cancelledByActorType', ${heartbeatRuns.resultJson}->'cancelledByActorType',
'interruptedByActorType', ${heartbeatRuns.resultJson}->'interruptedByActorType',
'reassignmentStopRequested', ${heartbeatRuns.resultJson}->'reassignmentStopRequested')`,
runnerProfileJson: sql<Record<string, unknown>>`jsonb_build_object(
'nativeWorkspaceSync', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceSync',
'remoteWorkspace', ${heartbeatRuns.runnerProfileJson}->'remoteWorkspace',
'legacyWorkspaceRecoveryMigration', ${heartbeatRuns.runnerProfileJson}->'legacyWorkspaceRecoveryMigration',
'nativeWorkspaceFinalizationOwner', ${heartbeatRuns.runnerProfileJson}->'nativeWorkspaceFinalizationOwner')` },
result: { id: nativeRunResults.id, sha256: nativeRunResults.canonicalSha256,
disposition: sql<string>`${nativeRunResults.resultJson}#>>'{result,reportedWorkDisposition}'`,
continuation: sql<string>`${nativeRunResults.resultJson}#>>'{result,continuation,kind}'`,
terminalDisposition: sql<string>`${nativeRunResults.resultJson}#>>'{terminal,reportedWorkDisposition}'`,
terminalState: sql<string>`${nativeRunResults.resultJson}#>>'{terminal,runTerminalState}'` },
contract: { id: completionContracts.id, sha256: completionContracts.canonicalSha256 },
actionId: issueRecoveryActions.id,
evidence: sql<Record<string, unknown>>`jsonb_build_object('runId', ${issueRecoveryActions.evidence}->'runId',
'systemRecovery', ${issueRecoveryActions.evidence}->'systemRecovery',
'workspaceRecovery', ${issueRecoveryActions.evidence}->'workspaceRecovery',
'workspaceFinalizationBlock', ${issueRecoveryActions.evidence}->'workspaceFinalizationBlock')`,
hasRecoveryHistory: sql<boolean>`exists (select 1 from jsonb_array_elements(${nativeRunFinalizations.recoveryHistory}) entry
where entry->>'kind' = 'preserved_result_finalization' and entry->>'resultId' = ${nativeRunResults.id}::text
and case when entry ? 'preservationGenerationId'
then entry->>'preservationGenerationId' = ${issueRecoveryActions.evidence}#>>'{systemRecovery,generationId}'
else entry->>'generationId' = ${issueRecoveryActions.evidence}#>>'{systemRecovery,generationId}' end)`,
}).from(heartbeatRuns)
.innerJoin(issues, and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId)))
.innerJoin(nativeRunFinalizations, and(eq(nativeRunFinalizations.companyId, binding.companyId),
eq(nativeRunFinalizations.issueId, binding.issueId), eq(nativeRunFinalizations.runId, binding.runId)))
.innerJoin(nativeRunResults, and(eq(nativeRunResults.companyId, binding.companyId),
eq(nativeRunResults.issueId, binding.issueId), eq(nativeRunResults.runId, binding.runId),
eq(nativeRunResults.id, nativeRunFinalizations.resultId), eq(nativeRunResults.schemaStatus, "accepted")))
.innerJoin(completionContracts, and(eq(completionContracts.companyId, binding.companyId),
eq(completionContracts.issueId, binding.issueId), eq(completionContracts.id, heartbeatRuns.completionContractId),
eq(completionContracts.id, nativeRunResults.completionContractId)))
.innerJoin(issueRecoveryActions, and(eq(issueRecoveryActions.companyId, binding.companyId),
eq(issueRecoveryActions.sourceIssueId, binding.issueId),
eq(issueRecoveryActions.kind, "active_run_watchdog"),
inArray(issueRecoveryActions.cause, ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"]),
completedWorkspaceRecoveryCondition(binding.runId),
sql`${issueRecoveryActions.evidence}->>'runId' = ${binding.runId}`))
.where(and(eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.id, binding.runId),
eq(heartbeatRuns.nativeIssueId, binding.issueId), eq(heartbeatRuns.agentId, binding.agentId),
eq(heartbeatRuns.runtimeMode, "native"), ne(nativeRunFinalizations.phase, "terminal_failure")))
.orderBy(sql`${issueRecoveryActions.createdAt} desc`).limit(1);
const [row] = await (lockProof ? query.for("share", { noWait: true }) : query);
if (!row || !["failed", "succeeded"].includes(row.run.status) || preservedResultHasStop(row.run.resultJson)
|| row.issue.assigneeAgentId !== binding.agentId || row.issue.assigneeUserId
|| ["done", "cancelled"].includes(row.issue.status) || row.issue.executionRunId || row.issue.checkoutRunId
|| row.run.runnerProfileJson.nativeWorkspaceFinalizationOwner || !row.hasRecoveryHistory
|| row.run.contractSha256 !== row.contract.sha256 || row.result.disposition !== "yielded"
|| row.result.terminalDisposition !== "yielded" || row.result.terminalState !== "succeeded"
|| row.result.continuation !== "response_wake") return null;
const recovery = readSystemRecovery(row.evidence), workspace = readWorkspaceRecovery(row.evidence);
if (recovery?.state !== "complete" || recovery.sourceRunId !== binding.runId || !recovery.preservationOperationIds.length
|| workspace?.status !== "recovered" || workspace.runId !== binding.runId) return null;
const [newer] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, binding.companyId), ne(heartbeatRuns.id, binding.runId),
gte(heartbeatRuns.createdAt, row.run.createdAt), or(eq(heartbeatRuns.issueId, binding.issueId),
eq(heartbeatRuns.nativeIssueId, binding.issueId), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${binding.issueId}`),
)).limit(1);
if (newer || await issueTreeControlService(db).getActivePauseHoldGate(binding.companyId, binding.issueId)
|| !await remoteExecutionHasStopped(db, binding.companyId, binding.runId)) return null;
const receiptId = await verifiedWorkspaceRepairReceipt(db, row.run);
if (!receiptId || !recovery.preservationOperationIds.includes(receiptId)) return null;
const block = nativeWorkspaceFinalizationBlockSchema.safeParse(row.evidence.workspaceFinalizationBlock);
const statusRestoration = row.issue.status === "blocked" && block.success
&& block.data.companyId === binding.companyId && block.data.issueId === binding.issueId
&& block.data.runId === binding.runId && block.data.agentId === binding.agentId
&& block.data.resultId === row.result.id && block.data.resultSha256 === row.result.sha256
&& block.data.contractId === row.contract.id && block.data.contractSha256 === row.contract.sha256
&& block.data.identityContextId === row.run.identityContextId && block.data.responsibleUserId === row.run.responsibleUserId
&& block.data.statusVersion === Number(row.issue.statusVersion)
&& block.data.priorDecisionId === row.issue.decisionId ? "owned" : "unproven";
const source: Omit<PreservedNativeResultFinalization, "fingerprint"> = {
companyId: binding.companyId, issueId: binding.issueId, runId: binding.runId, agentId: binding.agentId,
actionId: row.actionId, generationId: recovery.generationId,
resultId: row.result.id, resultSha256: row.result.sha256, contractId: row.contract.id,
contractSha256: row.contract.sha256, statusVersion: Number(row.issue.statusVersion),
identityContextId: row.run.identityContextId, responsibleUserId: row.run.responsibleUserId,
priorDecisionId: row.issue.decisionId, statusRestoration };
return { ...source, fingerprint: nativeSha256({ ...source, receiptId }) };
}