mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A native run saves its workspace in a `workspace_finalize` operation after the agent gives a result > - A crash loop can kill the server after it records this operation as `running` and before it finishes > - The next server resumes finalization and records a new attempt, which succeeds > - Nothing settles the first attempt, so it stays `running` forever and looks like live work > - This pull request settles the old attempt when a later attempt for the same run finishes, under the proof that the old controller is gone > - The benefit is that no workspace operation stays `running` after its owner is dead ## Linked Issues or Issue Description Refs #19666, #19759, #19721, #19717 **What happened?** A control-plane restart chaos test sent SIGKILL to the server while a native run finalized its workspace. The server crash-looped. One boot recorded a `workspace_finalize` operation as `running` and then died. A later boot resumed finalization. Its new operation (with `resumedFromOperationId` set to the old one) succeeded, and the run succeeded. The old operation stayed `running` at rest. **Expected behavior** When a later finalization attempt for the same run finishes, and the owner of the earlier attempt is proven gone, the earlier attempt gets a terminal status with a reason. A live owner keeps its attempt. **Steps to reproduce** 1. Start a native run that finalizes a workspace. 2. Kill the server with SIGKILL after it inserts the `workspace_finalize` row, and before it updates the row. 3. Restart the server and let it finalize the run again. 4. See that the first `workspace_finalize` row stays `running`. **Paperclip version or commit** `ef83d566ca` (also on `27170dca49`). ## What Changed - Root cause: `attemptNativeWorkspaceFinalization` in `server/src/services/native-runtime/native-workspace-finalizer.ts` reads the previous attempt only to set `resumedFromOperationId`. It records the new attempt but never settles the previous `running` attempt. The early "already succeeded" return path also left it alone. Only the recorder that started a row can settle it (`workspace-operations.ts` `recordOperation`), and a killed process never does. - Add `settleSupersededNativeWorkspaceFinalizations` in `native-workspace-finalization-supersession.ts`. It sets earlier `running` native finalize attempts for the run to `failed`. It writes `metadata.supersededFinalization` (reason `superseded_workspace_finalization`, superseding operation id and status), an stderr note, a terminal-workspace wake (registered before the write), and a `workspace_operation.reconciled` activity entry. - The proof is the run's workspace-finalization ownership, not age. The caller holds the advisory lock and the durable owner receipt. That claim requires the previous controller to have released it or to be proven stopped (same-host process proof, platform proof, or a completed copyback barrier). The function also skips an attempt that this process is still running, and attempts with independent obligations (`uncertain`, `saveWork`). It only touches rows that `native_workspace_finalizer` recorded and that are older than the superseding attempt. - The finalizer calls it after a new attempt reaches a terminal status, and on the "already succeeded" path. A settlement error is logged and does not change the finalization result. ## Verification - `cd server && npx vitest run src/services/native-runtime/native-workspace-finalization-supersession.test.ts src/services/native-runtime/native-workspace-finalizer.test.ts` on embedded Postgres: 72 passed. - New tests in `native-workspace-finalization-supersession.test.ts` (a separate file, so `native-workspace-finalizer.test.ts` stays under 1,000 lines): - A `running` finalize from a dead controller plus a resumed attempt that succeeds: the old row becomes `failed` with the supersession reason, no `running` rows remain, and one activity entry exists. - The resumed attempt fails: the old row is still settled, and `supersedingStatus` is `failed`. - A `running` finalize older than an already succeeded attempt: the old row is settled on the next sweep. - While another controller holds ownership, the `running` row stays `running`. - A `running` finalize recorded by another service stays `running`. - Attempts with `saveWork` or `uncertain: true` stay `running`. - Red/green: with the settle call disabled, the three settle tests fail (row stays `running`). With the fix, all pass. - `npx tsc --noEmit -p server` passes. ## Risks - Low. The change only writes rows that are already proven abandoned, and only under the finalization ownership that already fences copyback. - A settled attempt now reads as `failed`. Readers pick the latest attempt by creation time, and the superseding attempt is always later, so the run's finalization outcome does not change. - Existing leaked rows on runs that never finalize again stay `running`. This PR does not add a backfill. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edit) and code execution. ## 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>
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
import { classifyNativeWorkspaceFailure } from "./native-workspace-failure.js";
|
|
import { readRemoteWorkspaceAuthority, saveRemoteWorkspace } from "../remote-workspaces.js";
|
|
import { remoteWorkspaceFailureCause } 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, type NativeWorkspaceFinalizationOwnership } from "./native-workspace-finalization-ownership.js";
|
|
import { settleSupersededNativeWorkspaceFinalizations } from "./native-workspace-finalization-supersession.js";
|
|
import { logger } from "../../middleware/logger.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: cause ? { code, cause } : { 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");
|
|
}
|
|
|
|
// An attempt interrupted by a controller crash stays `running`; once this
|
|
// owner records a terminal attempt, settle the orphan under the same proof.
|
|
const settleSuperseded = async (ownership: NativeWorkspaceFinalizationOwnership,
|
|
superseding: { id: string; status: string }) => {
|
|
try {
|
|
await ownership.assertHeld();
|
|
await settleSupersededNativeWorkspaceFinalizations(input.db, {
|
|
companyId: bound.companyId, runId: input.runId, issueId: bound.issueId, superseding,
|
|
});
|
|
} catch (error) {
|
|
// Settlement is bookkeeping; it must not change the finalization outcome.
|
|
logger.warn({ runId: input.runId, error }, "Superseded workspace finalization could not be settled");
|
|
}
|
|
};
|
|
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) {
|
|
await settleSuperseded(ownership, 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. The redacted remote cause (for
|
|
// example a full sandbox disk or the failed capture step) is named on
|
|
// the operation; other error text stays off the record.
|
|
return workspaceSyncFailure(classifyNativeWorkspaceFailure(error).code === "workspace_sync_out_unrecoverable"
|
|
? "workspace_sync_out_unrecoverable" : "workspace_sync_out_failed", remoteWorkspaceFailureCause(error) ?? 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 },
|
|
};
|
|
},
|
|
});
|
|
await settleSuperseded(ownership, operation);
|
|
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; metadata?: Record<string, unknown> | 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,
|
|
failureCause: readString(record(record(operation.metadata).workspaceSync).cause),
|
|
});
|
|
}
|