mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native runs on a remote sandbox save two things after each turn: the provider checkpoint and the workspace export > - An agent can fill the sandbox disk during its own work, for example with a package install > - The turn still finishes, but both saves need free sandbox disk, so they fail and the task becomes blocked with no clear reason > - This pull request makes the checkpoint work on a full disk and makes a full disk visible in the export failure > - The benefit is fewer stranded checkpoints, and an operator sees at once why an export failed ## Linked Issues or Issue Description Related: #18715 added the shared-principal response-recovery control synchronized below. #18644 reduced the scratch disk that the workspace capture uses. #18172 added the disk-free streaming fallback to the Daytona directory sync-out. Both were deployed when this failure occurred. Neither changes the checkpoint copy, and neither can help when the disk has no free space. **What happened?** A native agent ran a package install for a large monorepo in a 10 GB Daytona sandbox. `df` in the run showed `10G 10G 4.6M 100%`. The agent committed its work and reported `done`. Then: 1. The provider checkpoint made a filtered copy of the provider directory inside the sandbox (`tar -cf archive; tar -xf archive -C snapshot`) to apply its exclusions. On the full disk, tar failed. The run recorded `runner_remote_checkpoint_snapshot_failed` without the reason, and the partial copy stayed on the disk. 2. Workspace finalization failed three times, and file preservation failed six times. The capture builds its archive inside the sandbox, so each attempt failed. Every failure was recorded as `remote_workspace_operation_failed` / `export_interrupted`, so nothing named the full disk. 3. The task went to `blocked`. The only copy of the commit was in the retained sandbox. **Expected behavior** The checkpoint does not need free sandbox disk when the provider can stream. A workspace export that cannot fit says that the sandbox disk is full. **Steps to reproduce** 1. Run a native agent with a provider whose checkpoint has exclusions (for example ACPX) in a small remote sandbox. 2. In the turn, fill the sandbox disk (for example `pnpm install` of a large repository, or `fallocate` of the free space) and finish the task. 3. The checkpoint fails with `runner_remote_checkpoint_snapshot_failed`, and every workspace export attempt fails with a generic code. **Paperclip version or commit** Observed on `410b0e27015`. This change is based on `5955dd4eb4c`. ## What Changed - Synchronize the shared-principal response-recovery regression with its saved comments and empty final deadline. Cover an in-flight discovery invalidation. Keep every authority lock, exact publication, error, and no-retry assertion. This changes test synchronization only. - `syncRemoteRunnerDirectoryOut`: when the in-sandbox checkpoint copy fails with a full-disk tar diagnostic, remove the partial copy and pass the exclusions to the provider sync-out (`exclude` on the directory mapping). The Daytona sync-out streams the directory without sandbox disk when it cannot write its archive. Other copy failures and timeouts still throw `runner_remote_checkpoint_snapshot_failed`. They now also remove the partial copy. - `remote-workspace-transport.ts`: the helper install and the capture scripts report `remote_workspace_sandbox_disk_full: ...` when Node reports `ENOSPC` or `EDQUOT`, or a Git or tar child reports "No space left on device". `command()` passes only that exact message through, like the existing unfinished-Git-operation message. - `native-workspace-finalizer.ts`: a remote save that fails with that message adds it to the failed operation's stderr. The failure code, the retry budget, and the recovery flow do not change. ## Verification - Current-head infrastructure interruption: the [Build job](https://github.com/paperclipai/paperclip/actions/runs/41637668555/job/125923826158) completed the actual build and production bundle boot, then the runner received a shutdown signal during the clean public Grok npm-install check. GitHub marked that step cancelled. The exact host-stop cause was not retained. One bounded rerun of only the failed Build and dependent `ci / verify` jobs passed on the unchanged head. [Build attempt 2](https://github.com/paperclipai/paperclip/actions/runs/41637668555/job/125931400745) completed successfully, including the clean Grok npm-install check. No product code, assertion or workflow changed for this interruption. - CI failure retained: commit `6c5937fcae` failed [chat shard 1/3](https://github.com/paperclipai/paperclip/actions/runs/41634638570/job/125913957217) because the shared-principal fixture saw zero saved comments after `worker.ready` and zero active work. The other chat child passed. The exact case passed once locally and then 30 times on the same Node 24.22.2 version as CI; those passes alone do not explain the CI interleaving. - Controlled RED/GREEN: invalidate the real database discovery once while its read is in flight. The worker correctly retains a future reconstruction deadline, but the old initial-pass/active-count barrier reproduces the same zero-versus-one comment assertion. Waiting for both saved comments and final `active=0`/`nextWakeAt=null` passes. Six real-PostgreSQL controls pass on final commit `a60e5629e8`, including endpoint independence, source revocation, and external lock failure/recovery. No sleep, timeout increase, weakened assertion, or product scheduling/authorization change was added. - A local server typecheck first lacked the checkout's built Runner declarations. After the declared Runner TypeScript build, the server typecheck passes on `a60e5629e8`. Final-head remote verification is complete: 54 successful checks, 3 expected skips, no failures or pending checks, and successful `ci / verify` and `ci / e2e` aggregates. Greptile is 5/5 on this exact commit with no unresolved review threads. - Merge preparation: rebased onto master `b22ef22d56` and kept both test-import additions. Added the checkpoint fallback and capacity-failure contract to `doc/execution-semantics.md`. On rebased commit `6c5937fcae`, 47 focused checkpoint, transport, finalization and retained warm-projection tests passed across three files. The name filter skipped 1,040 unrelated cases. Map and whitespace checks passed. The final-head CI and Greptile results are recorded above. - `npx vitest run server/src/services/native-runtime/native-session-executor.test.ts server/src/services/native-runtime/native-workspace-finalizer.test.ts server/src/services/remote-workspace-snapshot-archive.test.ts` passes (1,152 tests). - `npx vitest run server/src/services/remote-workspace-transport.test.ts server/src/services/native-runtime/native-workspace-finalizer-failure.test.ts` passes (30 tests). - New tests: - Checkpoint, with real files: a full-disk snapshot leaves a partial copy. The provider then gets the exclusions, the session files are kept, the package caches are left out, and the partial copy is removed. - Checkpoint: a permission failure and a timeout still fail, remove the partial copy, and do not call the sync-out. - Transport: the real capture scripts run with an `ENOSPC` preload, for the helper install and for the archive write. Both report the full-disk message and leave no partial attempt. A message with extra text still collapses to `remote_workspace_operation_failed`. - Finalizer: the operation stderr names the full disk and keeps `workspace_sync_out_failed`. Other failures keep the bare code. - With the source change removed, the 3 checkpoint cases and the 2 transport ENOSPC cases fail. - `npx tsc --noEmit -p server/tsconfig.json` is clean. - Suites that refer to the changed error codes (`explicit-native-continuation`, `native-recovery-notice-work`, `native-run-finalizer-telemetry`, `native-workspace-export-retry`, `remote-workspaces.integration`) pass. In `chat-channels.integration`, 2 cases fail locally on unmodified `master` too (a bundled skill path outside the checkout, and a chat run timing assertion). They are not related to this change. - Manual recovery of the observed task: the retained sandbox was resumed with free space, file preservation was requested again, and the saved workspace contained the agent's commit. ## Risks - The fallback runs only after the snapshot fails with a full-disk diagnostic. The normal path does not change. - Provider exclusions are not anchored to the directory root. In the fallback, a nested entry that has an excluded name (for example a nested `tmp` or `config.toml` in a Codex home) is also left out. The fallback never copies more than the snapshot, so credentials stay out of the checkpoint. - The fallback depends on the provider honoring `exclude`, which the sync protocol requires. The Daytona and Kubernetes providers honor it. - A workspace export on a full disk still fails. This change only names the cause. A full fix needs one of these: free-space reserve for the export, removal of re-creatable caches before the export, or a capture that streams its archive. Each of these changes behavior, so this pull request does not include one. - No migration. ## Model Used - OpenAI Codex, GPT-6 family, assisted with merge preparation, conflict resolution, documentation, test synchronization and verification. The harness does not expose the exact serving model ID or context-window size. - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edits, test runs). It did the root-cause analysis from run events, tool output, and sandbox inspection, wrote the code and tests, and ran local verification. ## 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 - [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> Co-authored-by: Dotta <bippadotta@protonmail.com>
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
import { classifyNativeWorkspaceFailure } from "./native-workspace-failure.js";
|
|
import { readRemoteWorkspaceAuthority, saveRemoteWorkspace } from "../remote-workspaces.js";
|
|
import { REMOTE_WORKSPACE_SANDBOX_DISK_FULL } from "../remote-workspace-transport.js";
|
|
import { recordNativeFinalizationFailure } from "./native-run-finalizer.js";
|
|
import { inspectPluginWorkspaceForFinalization } from "./native-plugin-workspace-finalization.js";
|
|
import type { WorkspaceWorkers } from "../plugin-workspaces.js";
|
|
import { restoreNativeWorkspaceBestEffort } from "./native-workspace-best-effort.js";
|
|
import { withNativeWorkspaceFinalizationOwnership } from "./native-workspace-finalization-ownership.js";
|
|
import fs from "node:fs/promises";
|
|
import { and, desc, eq } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
executionWorkspaces,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import { workspaceOperationService } from "../workspace-operations.js";
|
|
import { inspectManagedGitWorktreeBranch } from "../workspace-runtime.js";
|
|
import { environmentService } from "../environments.js";
|
|
import type { EnvironmentRuntimeService } from "../environment-runtime.js";
|
|
import { resolveEnvironmentExecutionTarget } from "../environment-execution-target.js";
|
|
import { measureWorkspaceSyncPhase } from "../execution-preparation-timing.js";
|
|
import {
|
|
readNativeWorkspaceSyncReference,
|
|
resumeNativeWorkspaceSync,
|
|
} from "./native-workspace-sync.js";
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function readString(value: unknown) {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function workspaceSyncFailure(
|
|
code: "workspace_sync_out_failed" | "workspace_sync_out_unrecoverable",
|
|
cause?: string,
|
|
) {
|
|
return {
|
|
status: "failed" as const,
|
|
exitCode: 1,
|
|
stderr: cause ? `${code}\n${cause}\n` : `${code}\n`,
|
|
metadata: { workspaceSync: { code } },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resume the real workspace-finalization action for a result-bearing native
|
|
* run. Durable sandbox-backed runs export and merge the remote workspace;
|
|
* older runs retain the local workspace validation path. The caller owns the
|
|
* durable retry and failure policy.
|
|
*/
|
|
export async function attemptNativeWorkspaceFinalization(input: {
|
|
db: Db;
|
|
runId: string;
|
|
environmentRuntime?: EnvironmentRuntimeService;
|
|
pluginWorkerManager?: WorkspaceWorkers;
|
|
}) {
|
|
const bound = await input.db.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
runnerProfileJson: heartbeatRuns.runnerProfileJson,
|
|
issueId: nativeRunFinalizations.issueId,
|
|
resultId: nativeRunFinalizations.resultId,
|
|
issueWorkspaceId: issues.executionWorkspaceId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(nativeRunFinalizations, eq(nativeRunFinalizations.runId, heartbeatRuns.id))
|
|
.innerJoin(issues, and(
|
|
eq(issues.id, nativeRunFinalizations.issueId),
|
|
eq(issues.companyId, heartbeatRuns.companyId),
|
|
))
|
|
.where(eq(heartbeatRuns.id, input.runId))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!bound || bound.runtimeMode !== "native" || !bound.resultId) {
|
|
throw new Error("native_workspace_finalization_binding_missing");
|
|
}
|
|
|
|
const owned = await withNativeWorkspaceFinalizationOwnership({
|
|
db: input.db, companyId: bound.companyId, runId: input.runId,
|
|
}, async (ownership) => {
|
|
// A sweep can be admitted before the live owner publishes a permanent
|
|
// failure or retry delay, then acquire ownership after that owner releases.
|
|
// Recheck admission inside the lock before any copyback or operation receipt.
|
|
const admission = await input.db.select({
|
|
phase: nativeRunFinalizations.phase,
|
|
nextAttemptAt: nativeRunFinalizations.nextAttemptAt,
|
|
resultId: nativeRunFinalizations.resultId,
|
|
}).from(nativeRunFinalizations).where(and(
|
|
eq(nativeRunFinalizations.runId, input.runId),
|
|
eq(nativeRunFinalizations.companyId, bound.companyId),
|
|
eq(nativeRunFinalizations.issueId, bound.issueId),
|
|
)).limit(1).then((rows) => rows[0] ?? null);
|
|
if (!admission || admission.resultId !== bound.resultId) {
|
|
throw new Error("native_workspace_finalization_binding_missing");
|
|
}
|
|
if (admission.phase === "terminal_failure") return { kind: "inactive" } as const;
|
|
if (admission.nextAttemptAt && admission.nextAttemptAt > new Date()) {
|
|
return { kind: "retry", retryAt: admission.nextAttemptAt } as const;
|
|
}
|
|
const successful = await input.db.select().from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, bound.companyId),
|
|
eq(workspaceOperations.heartbeatRunId, input.runId),
|
|
eq(workspaceOperations.issueId, bound.issueId),
|
|
eq(workspaceOperations.phase, "workspace_finalize"),
|
|
eq(workspaceOperations.status, "succeeded"),
|
|
)).orderBy(desc(workspaceOperations.createdAt)).limit(1).then((rows) => rows[0] ?? null);
|
|
// A stale failure from a pre-fencing controller cannot invalidate exported work.
|
|
if (successful) return { kind: "completed", operation: successful } as const;
|
|
const previous = await input.db.select().from(workspaceOperations).where(and(
|
|
eq(workspaceOperations.companyId, bound.companyId),
|
|
eq(workspaceOperations.heartbeatRunId, input.runId),
|
|
eq(workspaceOperations.issueId, bound.issueId),
|
|
eq(workspaceOperations.phase, "workspace_finalize"),
|
|
)).orderBy(desc(workspaceOperations.createdAt)).limit(1).then((rows) => rows[0] ?? null);
|
|
|
|
const persistedInput = record(
|
|
record(bound.runnerProfileJson).nativeExecutionInput,
|
|
);
|
|
const nativeWorkspaceSync = readNativeWorkspaceSyncReference(
|
|
record(bound.runnerProfileJson).nativeWorkspaceSync,
|
|
);
|
|
const binding = record(persistedInput.binding);
|
|
const workspaceId = previous?.executionWorkspaceId
|
|
?? bound.issueWorkspaceId
|
|
?? readString(binding.executionWorkspaceId);
|
|
const workspace = workspaceId
|
|
? await input.db.select().from(executionWorkspaces).where(and(
|
|
eq(executionWorkspaces.id, workspaceId),
|
|
eq(executionWorkspaces.companyId, bound.companyId),
|
|
)).limit(1).then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const cwd = workspace?.providerRef ?? workspace?.cwd ?? previous?.cwd ?? null;
|
|
|
|
const recorder = workspaceOperationService(input.db).createRecorder({
|
|
companyId: bound.companyId,
|
|
heartbeatRunId: input.runId,
|
|
// The native binding also uses this field as a directory-only containment token.
|
|
// Only attach it to the operation when it resolves to a company-owned row, because
|
|
// workspace_operations.execution_workspace_id is a real execution-workspace FK.
|
|
executionWorkspaceId: workspace?.id ?? null,
|
|
issueId: bound.issueId,
|
|
});
|
|
const operation = await recorder.recordOperation({
|
|
phase: "workspace_finalize",
|
|
cwd,
|
|
metadata: {
|
|
resumedFromOperationId: previous?.id ?? null,
|
|
owningService: "native_workspace_finalizer",
|
|
observation: workspace?.strategyType === "git_worktree"
|
|
? "managed_git_worktree_branch"
|
|
: "workspace_directory",
|
|
},
|
|
run: async () => {
|
|
await ownership.assertHeld();
|
|
const remoteAuthority = readRemoteWorkspaceAuthority(workspace?.metadata);
|
|
if (remoteAuthority && workspace) {
|
|
if (!input.environmentRuntime || !remoteAuthority.location) {
|
|
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
}
|
|
const location = remoteAuthority.location;
|
|
const environmentsSvc = environmentService(input.db);
|
|
const lease = await environmentsSvc.getLeaseById(location.leaseId);
|
|
const environment = lease?.environmentId ? await environmentsSvc.getById(lease.environmentId) : null;
|
|
if (!lease || !environment || lease.companyId !== bound.companyId
|
|
|| lease.heartbeatRunId !== input.runId || lease.executionWorkspaceId !== workspace.id
|
|
|| lease.providerLeaseId !== location.providerLeaseId || environment.id !== location.environmentId
|
|
|| ["expired", "failed", "pending_cleanup"].includes(lease.status)) {
|
|
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
}
|
|
const target = await resolveEnvironmentExecutionTarget({
|
|
db: input.db, companyId: bound.companyId, adapterType: "paperclip_runner",
|
|
environment, leaseId: lease.id, leaseMetadata: lease.metadata, lease,
|
|
environmentRuntime: input.environmentRuntime,
|
|
});
|
|
if (!target) return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
try {
|
|
const saved = await saveRemoteWorkspace({
|
|
db: input.db, workspace, runId: input.runId, lease, target,
|
|
assertOwned: ownership.assertHeld,
|
|
});
|
|
await ownership.assertHeld();
|
|
return { status: "succeeded", exitCode: 0,
|
|
system: "Native workspace files were saved to durable storage.\n",
|
|
metadata: { remoteWorkspace: saved } };
|
|
} catch (error) {
|
|
await ownership.assertHeld();
|
|
// The code keeps the retry policy. A full sandbox disk is named on the
|
|
// operation, because no retry can save the workspace until space is freed.
|
|
const diskFull = error instanceof Error && error.message === REMOTE_WORKSPACE_SANDBOX_DISK_FULL;
|
|
return workspaceSyncFailure(classifyNativeWorkspaceFailure(error).code === "workspace_sync_out_unrecoverable"
|
|
? "workspace_sync_out_unrecoverable" : "workspace_sync_out_failed", diskFull ? REMOTE_WORKSPACE_SANDBOX_DISK_FULL : undefined);
|
|
}
|
|
}
|
|
if (nativeWorkspaceSync) {
|
|
if (!input.environmentRuntime) {
|
|
return {
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stderr:
|
|
"Native workspace finalization cannot access the environment runtime.\n",
|
|
};
|
|
}
|
|
const environmentsSvc = environmentService(input.db);
|
|
const lease = await environmentsSvc.getLeaseById(
|
|
nativeWorkspaceSync.leaseId,
|
|
);
|
|
const environment = lease?.environmentId
|
|
? await environmentsSvc.getById(lease.environmentId)
|
|
: null;
|
|
if (
|
|
!lease ||
|
|
!environment ||
|
|
lease.companyId !== bound.companyId ||
|
|
environment.id !== lease.environmentId
|
|
) {
|
|
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
}
|
|
if (
|
|
lease.status === "expired" ||
|
|
lease.status === "failed" ||
|
|
lease.status === "pending_cleanup" ||
|
|
!lease.providerLeaseId ||
|
|
lease.providerLeaseId !== nativeWorkspaceSync.providerLeaseId
|
|
) {
|
|
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
}
|
|
const target = await resolveEnvironmentExecutionTarget({
|
|
db: input.db,
|
|
companyId: bound.companyId,
|
|
adapterType: "paperclip_runner",
|
|
environment,
|
|
leaseId: lease.id,
|
|
leaseMetadata: lease.metadata,
|
|
lease,
|
|
environmentRuntime: input.environmentRuntime,
|
|
});
|
|
if (!target) {
|
|
return {
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stderr: "Native workspace finalization target is unavailable.\n",
|
|
};
|
|
}
|
|
try {
|
|
const restored = await restoreNativeWorkspaceBestEffort({
|
|
db: input.db,
|
|
runId: input.runId,
|
|
assertOwnership: ownership.assertHeld,
|
|
restore: () => resumeNativeWorkspaceSync({
|
|
db: input.db, runId: input.runId, target, assertOwnership: ownership.assertHeld,
|
|
onPhase: measureWorkspaceSyncPhase,
|
|
}),
|
|
});
|
|
await ownership.assertHeld();
|
|
if (restored === false) {
|
|
return workspaceSyncFailure("workspace_sync_out_unrecoverable");
|
|
}
|
|
return {
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
system:
|
|
restored
|
|
? "Native workspace finalization restored the remote workspace.\n"
|
|
: "Unsafe workspace export omitted; finalization completed.\n",
|
|
metadata: {
|
|
workspaceSync: {
|
|
schema: nativeWorkspaceSync.schema,
|
|
workspaceId: nativeWorkspaceSync.workspaceId,
|
|
leaseId: nativeWorkspaceSync.leaseId,
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await ownership.assertHeld();
|
|
const code = classifyNativeWorkspaceFailure(error).code === "workspace_sync_out_unrecoverable"
|
|
? "workspace_sync_out_unrecoverable" : "workspace_sync_out_failed";
|
|
return workspaceSyncFailure(code);
|
|
}
|
|
}
|
|
let pluginWorkspace: Record<string, unknown> | null;
|
|
try {
|
|
pluginWorkspace = workspace
|
|
? await inspectPluginWorkspaceForFinalization(input.db, input.pluginWorkerManager, workspace, bound.agentId, input.runId)
|
|
: null;
|
|
} catch (error) {
|
|
await ownership.assertHeld();
|
|
const message = error instanceof Error ? error.message : null;
|
|
const code = message === "plugin_workspace_branch_mismatch" || message === "plugin_workspace_owner_mismatch"
|
|
? message : "plugin_workspace_inspection_failed";
|
|
return { status: "failed", exitCode: 1, stderr: `${code}\n` };
|
|
}
|
|
await ownership.assertHeld();
|
|
if (pluginWorkspace) return { status: "succeeded", exitCode: 0,
|
|
system: "Native workspace finalization used the plugin workspace owner.\n",
|
|
metadata: { pluginWorkspace } };
|
|
if (!cwd) {
|
|
return {
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stderr: "Native workspace finalization cannot resolve the persisted workspace path.\n",
|
|
};
|
|
}
|
|
const isDirectory = await fs.stat(cwd).then((stat) => stat.isDirectory()).catch(() => false);
|
|
await ownership.assertHeld();
|
|
if (!isDirectory) {
|
|
return {
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stderr: `Native workspace finalization could not access workspace directory ${cwd}.\n`,
|
|
};
|
|
}
|
|
if (workspace?.strategyType === "git_worktree") {
|
|
const inspection = await inspectManagedGitWorktreeBranch({
|
|
worktreePath: cwd,
|
|
expectedBranchName: workspace.branchName,
|
|
});
|
|
if (!inspection.valid) {
|
|
return {
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stderr: `Managed git worktree validation failed: ${inspection.reason ?? "unknown failure"}.\n`,
|
|
metadata: { managedGitWorktreeBranch: inspection },
|
|
};
|
|
}
|
|
return {
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
system: "Native workspace finalization validated the managed git worktree.\n",
|
|
metadata: { managedGitWorktreeBranch: inspection },
|
|
};
|
|
}
|
|
return {
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
system: "Native workspace finalization validated the persisted workspace directory.\n",
|
|
metadata: { observedWorkspacePath: cwd },
|
|
};
|
|
},
|
|
});
|
|
return { kind: "completed", operation } as const;
|
|
});
|
|
return owned.acquired ? owned.value : { kind: "waiting", reason: owned.reason } as const;
|
|
}
|
|
|
|
|
|
|
|
export async function resumeNativeWorkspaceFinalization(input: Parameters<typeof attemptNativeWorkspaceFinalization>[0]) {
|
|
const result = await attemptNativeWorkspaceFinalization(input);
|
|
return result.kind === "completed" ? result.operation : null;
|
|
}
|
|
|
|
export function recordNativeWorkspaceFinalizationFailure(db: Db, runId: string, operation: { stderrExcerpt: string | null }) {
|
|
const unrecoverable = operation.stderrExcerpt?.includes("workspace_sync_out_unrecoverable");
|
|
const pluginFailure = operation.stderrExcerpt?.trim();
|
|
const pluginRepair = pluginFailure === "plugin_workspace_branch_mismatch" || pluginFailure === "plugin_workspace_owner_mismatch";
|
|
return recordNativeFinalizationFailure({
|
|
db, runId,
|
|
error: new Error(pluginRepair ? `native_${pluginFailure}` : unrecoverable
|
|
? "native_workspace_sync_out_unrecoverable" : "native_workspace_sync_out_failed"),
|
|
projectRunStatus: true, failureScope: "workspace", permanent: unrecoverable,
|
|
});
|
|
}
|