mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## 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>
241 lines
20 KiB
TypeScript
241 lines
20 KiB
TypeScript
import { hasMigratedLegacyWorkspaceSource } from "./legacy-workspace-recovery-migration.js";
|
|
import { retiredReusableLeasePredecessors } from "./environment-lease-handoff.js";
|
|
import { and, eq, inArray, ne, or, sql } from "drizzle-orm";
|
|
import { environmentLeases, environments, executionWorkspaces, executionWorkspaceSnapshots, heartbeatRuns, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
|
|
import { adapterExecutionControls } from "./adapter-execution-control.js";
|
|
import { readNativeWorkspaceSyncReference } from "./native-runtime/native-workspace-sync.js";
|
|
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 { 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"];
|
|
export const terminalWorkspaceRunStates = ["failed", "interrupted", "timed_out", "cancelled", "succeeded"];
|
|
export type WorkspaceRecoverySourceIdentity = { companyId: string; issueId: string; runId: string; actionId: string };
|
|
|
|
export function workspaceSourceBindingFailure(reason: WorkspaceSourceVerificationFailure["reason"], policy?: unknown) {
|
|
const observedLeasePolicy = typeof policy === "string" && (ENVIRONMENT_LEASE_POLICIES as readonly string[]).includes(policy)
|
|
? policy as EnvironmentLeasePolicy : "unrecognized";
|
|
return new SystemRecoveryFailure("source_binding_invalid", "service_required", undefined, {
|
|
reason, ...(reason === "lease_policy_not_retained" ? { observedLeasePolicy } : {}),
|
|
});
|
|
}
|
|
|
|
export class WorkspaceSourceInspectionLimit extends Error {}
|
|
|
|
export function workspaceRecoverySourceEligible(includeCompleted = false) {
|
|
const actionEligible = sql`(${issueRecoveryActions.status} in ('active', 'escalated') or
|
|
(${issueRecoveryActions.status} = 'cancelled'
|
|
and ${inArray(issueRecoveryActions.cause, workspaceExportCauses)}
|
|
and jsonb_typeof(${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}') = 'array'
|
|
and ${issueRecoveryActions.evidence}#>'{workspaceRecovery,requestIds}' <> '[]'::jsonb) or
|
|
(${issueRecoveryActions.status} = 'resolved' and (
|
|
(${issueRecoveryActions.evidence}#>>'{automaticRecovery,policy}' = 'preserve_without_replay_v1'
|
|
and ${issueRecoveryActions.evidence}#>>'{automaticRecovery,replay}' = 'blocked'
|
|
and ${issueRecoveryActions.evidence}#>>'{automaticRecovery,runId}' = ${heartbeatRuns.id}::text) or
|
|
(${issueRecoveryActions.evidence}#>>'{historicalWorkspaceSource,schema}' = 'paperclip.historical-workspace-source.v1'
|
|
and ${issueRecoveryActions.evidence}#>>'{historicalWorkspaceSource,runId}' = ${heartbeatRuns.id}::text))))`;
|
|
return and(
|
|
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
|
|
includeCompleted ? sql`(${actionEligible} or ${issueRecoveryActions.evidence}#>>'{systemRecovery,state}' = 'complete')` : actionEligible,
|
|
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${issueRecoveryActions.sourceIssueId}::text`,
|
|
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,sourceRunId}' = ${heartbeatRuns.id}::text`,
|
|
sql`${issueRecoveryActions.evidence}#>>'{workspaceRecovery,runId}' = ${heartbeatRuns.id}::text`,
|
|
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,policyVersion}' = '1'`,
|
|
sql`${issueRecoveryActions.evidence}#>>'{systemRecovery,schema}' = 'paperclip.system-recovery.v1'`,
|
|
sql`${issueRecoveryActions.evidence}#>>'{workspaceRecovery,schema}' = 'paperclip.workspace-recovery.v1'`,
|
|
sql`(${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = 'paperclip.workspace-restore-recovery.v1'
|
|
or ${issueRecoveryActions.evidence}->'historicalWorkspaceSource'->>'schema' = 'paperclip.historical-workspace-source.v1'
|
|
or ${issueRecoveryActions.cause} in ('native_workspace_sync_out_unrecoverable', 'native_workspace_sync_out_retry_exhausted'))`,
|
|
);
|
|
}
|
|
|
|
/** The preservation owner may reopen only its exact stop-only allocation.
|
|
* This does not release the source's ordinary execution or destruction fence. */
|
|
export async function ownedWorkspaceRecoveryLease(db: Db, input: WorkspaceRecoverySourceIdentity & {
|
|
leaseId: string; environmentId: string; ownership: NativeWorkspaceFinalizationOwnership;
|
|
}) {
|
|
await input.ownership.assertHeld();
|
|
const inspected = await inspectWorkspaceRecoverySource(db, input);
|
|
const source = inspected.sources.find(item => item.lease.id === input.leaseId);
|
|
if (!source) throw workspaceSourceBindingFailure("recovery_lease_not_selected");
|
|
if (source.environment.id !== input.environmentId) throw workspaceSourceBindingFailure("recovery_environment_mismatch");
|
|
if (source.lease.status !== "pending_cleanup" || source.lease.metadata?.pendingCleanupInFlight !== true)
|
|
throw workspaceSourceBindingFailure("recovery_lease_not_claimed");
|
|
if (source.lease.metadata?.pendingCleanupAttemptId !== input.ownership.token)
|
|
throw workspaceSourceBindingFailure("recovery_owner_mismatch");
|
|
if (!readStopOnlyCleanup(source.lease)) throw workspaceSourceBindingFailure("recovery_stop_intent_missing");
|
|
await input.ownership.assertHeld();
|
|
return source;
|
|
}
|
|
|
|
/** Resolve only the immutable source recorded by the failed run. Never discover
|
|
* a candidate by recency, mutable adapter configuration, or a provider search. */
|
|
export async function inspectWorkspaceRecoverySource(db: Db, input: WorkspaceRecoverySourceIdentity,
|
|
options: { maximumSources?: number; prepareNativeRetention?: boolean } = {}) {
|
|
const invalid = workspaceSourceBindingFailure;
|
|
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId)));
|
|
const [action] = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.companyId, input.companyId),
|
|
eq(issueRecoveryActions.sourceIssueId, input.issueId), eq(issueRecoveryActions.id, input.actionId)));
|
|
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;
|
|
const reference = readNativeWorkspaceSyncReference(rawReference);
|
|
if (rawReference != null && !reference) throw invalid("native_reference_invalid");
|
|
const rawRemoteReference = run.runnerProfileJson?.remoteWorkspace;
|
|
const parsedRemote = remoteWorkspaceRunReferenceSchema.safeParse(rawRemoteReference);
|
|
const remoteReference = parsedRemote.success ? parsedRemote.data : null;
|
|
if ((rawRemoteReference != null && !remoteReference) || (reference && remoteReference)) throw invalid("native_reference_invalid");
|
|
const recoveryReference = reference ?? remoteReference;
|
|
const [remoteWorkspace] = remoteReference ? await db.select().from(executionWorkspaces).where(and(
|
|
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, remoteReference.workspaceId),
|
|
)) : [];
|
|
const remoteAuthority = readRemoteWorkspaceAuthority(remoteWorkspace?.metadata);
|
|
if (remoteReference && (!remoteWorkspace || !remoteAuthority?.location || remoteAuthority.epoch !== remoteReference.epoch
|
|
|| remoteAuthority.location.leaseId !== remoteReference.leaseId || remoteAuthority.location.providerLeaseId !== remoteReference.providerLeaseId
|
|
|| remoteAuthority.location.remoteCwd !== remoteReference.remoteCwd
|
|
|| (remoteReference.state === "prepared" ? !remoteAuthority.dirty || remoteAuthority.writerRunId !== run.id
|
|
: remoteAuthority.dirty || remoteAuthority.writerRunId !== null || remoteAuthority.snapshotId !== remoteReference.snapshotId))) {
|
|
throw invalid("workspace_reference_mismatch");
|
|
}
|
|
if (remoteReference?.state === "saved") {
|
|
const [snapshot] = await db.select({ id: executionWorkspaceSnapshots.id }).from(executionWorkspaceSnapshots).where(and(
|
|
eq(executionWorkspaceSnapshots.companyId, input.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, remoteReference.workspaceId),
|
|
eq(executionWorkspaceSnapshots.id, remoteReference.snapshotId!), eq(executionWorkspaceSnapshots.status, "committed"),
|
|
));
|
|
if (!snapshot) throw invalid("workspace_reference_mismatch");
|
|
}
|
|
const recorded = run.resultJson?.workspaceRestoreRecovery as { schema?: unknown; leaseIds?: unknown } | undefined;
|
|
if (recorded != null && recorded.schema !== "paperclip.workspace-restore-recovery.v1") throw invalid("restore_reference_invalid");
|
|
const historical = action.evidence.historicalWorkspaceSource as { schema?: unknown; runId?: unknown; leaseIds?: unknown } | undefined;
|
|
if (historical != null && (historical.schema !== "paperclip.historical-workspace-source.v1" || historical.runId !== run.id)) throw invalid("historical_reference_invalid");
|
|
const leaseIds = recorded ? recorded.leaseIds
|
|
: recoveryReference && workspaceExportCauses.includes(action.cause) ? [recoveryReference.leaseId]
|
|
: historical?.leaseIds;
|
|
if (!Array.isArray(leaseIds) || leaseIds.length === 0)
|
|
throw new SystemRecoveryFailure("historical_source_missing", "use_alternative_strategy");
|
|
if (options.maximumSources !== undefined && leaseIds.length > options.maximumSources)
|
|
throw new WorkspaceSourceInspectionLimit();
|
|
if (leaseIds.some(id => typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) ||
|
|
new Set(leaseIds).size !== leaseIds.length || (recoveryReference && !leaseIds.includes(recoveryReference.leaseId))) throw invalid("lease_reference_invalid");
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, run.id)));
|
|
if (coordinator?.leaseOwner || adapterExecutionControls.has(run.id) ||
|
|
(run.controllerLeaseExpiresAt && run.controllerLeaseExpiresAt > new Date()))
|
|
throw new SystemRecoveryFailure("agent_stop_unconfirmed", "recheck_evidence");
|
|
// Reusable allocations can still owe copyback for a completed native result.
|
|
// This authorizes only preservation of the exact descriptor, never reuse for
|
|
// agent execution. Stop verification and physical ownership checks remain below.
|
|
const acceptedExportResult = recoveryReference && workspaceExportCauses.includes(action.cause)
|
|
&& run.runtimeMode === "native" && coordinator?.issueId === input.issueId
|
|
&& coordinator.resultId && run.completionContractId
|
|
? (await db.select({ id: nativeRunResults.id }).from(nativeRunResults).where(and(
|
|
eq(nativeRunResults.companyId, input.companyId), eq(nativeRunResults.issueId, input.issueId),
|
|
eq(nativeRunResults.runId, run.id), eq(nativeRunResults.id, coordinator.resultId),
|
|
eq(nativeRunResults.completionContractId, run.completionContractId),
|
|
eq(nativeRunResults.schemaStatus, "accepted"),
|
|
sql`${nativeRunResults.resultJson}#>>'{terminal,runTerminalState}' = 'succeeded'`,
|
|
)).limit(1))[0] : null;
|
|
const sources = [];
|
|
for (const leaseId of leaseIds as string[]) {
|
|
const [lease] = await db.select().from(environmentLeases).where(and(eq(environmentLeases.companyId, input.companyId),
|
|
eq(environmentLeases.id, leaseId), eq(environmentLeases.heartbeatRunId, run.id)));
|
|
const legacyDescriptor = reference?.leaseId === leaseId ? reference : null;
|
|
const remoteDescriptor = remoteReference?.leaseId === leaseId ? remoteReference : null;
|
|
const descriptor = legacyDescriptor ?? remoteDescriptor;
|
|
const remoteCwd = lease?.metadata?.remoteCwd;
|
|
if (!lease) throw invalid("source_lease_not_bound");
|
|
if (lease.issueId !== input.issueId) throw invalid("lease_issue_mismatch");
|
|
if (!lease.environmentId) throw invalid("lease_environment_missing");
|
|
if (!lease.providerLeaseId) throw invalid("lease_allocation_missing");
|
|
if (!lease.provider || lease.provider === "local") throw invalid("lease_provider_invalid");
|
|
const preparingRetention = options.prepareNativeRetention && run.runtimeMode === "native" && run.status === "failed"
|
|
&& legacyDescriptor?.state === "prepared" && legacyDescriptor.resourceDisposition === "keep_running"
|
|
&& lease.leasePolicy === "reuse_by_environment" && ["released", "retained"].includes(lease.status);
|
|
const reusableExport = descriptor && (acceptedExportResult || remoteDescriptor && remoteAuthority)
|
|
&& ["reuse_by_environment", "reuse_by_execution_workspace"].includes(lease.leasePolicy);
|
|
if (!["retain_on_failure", "ephemeral"].includes(lease.leasePolicy) && !reusableExport && !preparingRetention)
|
|
throw invalid("lease_policy_not_retained", lease.leasePolicy);
|
|
if (typeof remoteCwd !== "string" || !remoteCwd.startsWith("/")) throw invalid("lease_cwd_invalid");
|
|
if (descriptor && lease.providerLeaseId !== descriptor.providerLeaseId) throw invalid("allocation_reference_mismatch");
|
|
if (descriptor && remoteCwd !== descriptor.remoteCwd) throw invalid("cwd_reference_mismatch");
|
|
if (descriptor && lease.executionWorkspaceId && lease.executionWorkspaceId !== descriptor.workspaceId)
|
|
throw invalid("workspace_reference_mismatch");
|
|
if (remoteDescriptor && lease.environmentId !== remoteAuthority!.location!.environmentId)
|
|
throw invalid("workspace_reference_mismatch");
|
|
const history = await db.select().from(environmentLeases).where(and(
|
|
ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider),
|
|
eq(environmentLeases.providerLeaseId, lease.providerLeaseId)));
|
|
const predecessors = retiredReusableLeasePredecessors(lease, history);
|
|
if (!predecessors || (predecessors.length && ((run.runtimeMode !== "native" && !remoteDescriptor) || !descriptor || lease.metadata?.agentId !== run.agentId)))
|
|
throw new SystemRecoveryFailure("allocation_ownership_ambiguous", "service_required");
|
|
const predecessorRuns = predecessors.map(item => item.heartbeatRunId!);
|
|
const priors = predecessorRuns.length ? await db.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, input.companyId), inArray(heartbeatRuns.id, predecessorRuns))) : [];
|
|
const priorOwners = predecessorRuns.length ? await db.select({ runId: nativeRunFinalizations.runId,
|
|
phase: nativeRunFinalizations.phase, leaseOwner: nativeRunFinalizations.leaseOwner }).from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.companyId, input.companyId), inArray(nativeRunFinalizations.runId, predecessorRuns),
|
|
eq(nativeRunFinalizations.issueId, input.issueId))) : [];
|
|
for (const predecessor of predecessors) {
|
|
const prior = priors.find(item => item.id === predecessor.heartbeatRunId);
|
|
const priorReference = readNativeWorkspaceSyncReference(prior?.runnerProfileJson?.nativeWorkspaceSync);
|
|
const parsedPriorRemote = remoteWorkspaceRunReferenceSchema.safeParse(prior?.runnerProfileJson?.remoteWorkspace);
|
|
const priorRemote = parsedPriorRemote.success ? parsedPriorRemote.data : null;
|
|
const priorDescriptor = priorReference ?? priorRemote;
|
|
const priorSaved = priorReference ? priorReference.state === "finalized" && Boolean(priorReference.finalHostSha256)
|
|
: priorRemote?.state === "saved" && Boolean(priorRemote.snapshotId) && Boolean(remoteDescriptor)
|
|
&& priorRemote.epoch < remoteDescriptor!.epoch;
|
|
const priorOwner = priorOwners.find(item => item.runId === predecessor.heartbeatRunId);
|
|
if (!prior || prior.runtimeMode !== "native" || prior.agentId !== run.agentId || prior.nativeIssueId !== input.issueId ||
|
|
!terminalWorkspaceRunStates.includes(prior.status) || adapterExecutionControls.has(prior.id) ||
|
|
(prior.controllerLeaseExpiresAt && prior.controllerLeaseExpiresAt > new Date()) ||
|
|
!priorOwner || priorOwner.leaseOwner || !["committed", "terminal_failure", "applied"].includes(priorOwner.phase) ||
|
|
!priorDescriptor || !priorSaved || priorDescriptor.leaseId !== predecessor.id || priorDescriptor.providerLeaseId !== lease.providerLeaseId ||
|
|
priorDescriptor.workspaceId !== descriptor!.workspaceId || priorDescriptor.remoteCwd !== remoteCwd)
|
|
throw new SystemRecoveryFailure("allocation_ownership_ambiguous", "service_required");
|
|
}
|
|
// Snapshot export writes only a new staging directory. A native baseline
|
|
// merge additionally needs exclusive ownership of its host destination.
|
|
if (descriptor) {
|
|
const [writer] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, input.companyId), inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
sql`not exists (select 1 from ${issueRecoveryActions} a
|
|
where a.company_id = ${input.companyId} and a.source_issue_id = ${input.issueId}
|
|
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);
|
|
if (writer) throw new SystemRecoveryFailure("workspace_writer_active", "recheck_evidence");
|
|
}
|
|
const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId));
|
|
if (!environment) throw new SystemRecoveryFailure("source_unavailable", "service_required");
|
|
const preservationOnly = legacyDescriptor ? await hasMigratedLegacyWorkspaceSource(db, {
|
|
companyId: input.companyId, runId: run.id, reference: legacyDescriptor,
|
|
}) : false;
|
|
sources.push({ lease, environment, reference: legacyDescriptor, preservationOnly, remoteReference: remoteDescriptor,
|
|
remoteWorkspace: remoteDescriptor ? remoteWorkspace : null, remoteCwd });
|
|
}
|
|
return { run, action, sources };
|
|
}
|