diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 033bf6034d..5f94040ce6 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -43,6 +43,8 @@ import { stripPaperclipSessionMetadataFromSessionParams, normalizeSessionParams, shouldResetTaskSessionForWake, + scrubGitCredentialText, + buildAnchorFallbackWorkspaceNotes, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts"; @@ -59,6 +61,8 @@ function buildResolvedWorkspace(overrides: Partial = {} repoRef: null, workspaceHints: [], warnings: [], + baseCwdFallback: false, + materializationFailures: [], ...overrides, }; } @@ -561,6 +565,157 @@ describe("assertGitWorktreeBaseWorkspaceReady", () => { } }); + it("rejects isolated git worktrees when the project workspace could not be materialized, even if the fallback cwd is a git checkout", async () => { + // The fallback agent-home dir being a git repo must not let the run proceed: it would be an + // unrelated repository, and the not-a-git-checkout probe would mask the real clone failure. + const cwd = await createGitCheckout({ withRemote: false }); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: "https://github.com/example/private-repo.git", + repoRef: "origin/master", + }, + anchor: { + baseCwdFallback: true, + materializationFailures: [{ + projectWorkspaceId: "workspace-1", + repoUrl: "https://github.com/example/private-repo.git", + error: 'Failed to prepare managed checkout for "https://github.com/example/private-repo.git": fatal: could not read Username', + }], + }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + message: expect.stringContaining("could not be prepared: Failed to prepare managed checkout"), + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_materialization_failed", + issueId: "issue-1", + baseCwdFallback: true, + materializationFailures: [expect.objectContaining({ + projectWorkspaceId: "workspace-1", + error: expect.stringContaining("could not read Username"), + })], + }), + }, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("keeps the not-a-git-checkout reason for a fallback with no failed materialization attempt", async () => { + // A configured path that is simply unavailable is not a clone failure; the message must + // not steer the operator toward repairing clone access. + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-unavailable-path-fallback-")); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: null, + }, + anchor: { baseCwdFallback: true }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + message: expect.stringContaining("is not a git checkout"), + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_not_git_checkout", + }), + }, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("refuses a git-checkout fallback cwd that is not the project workspace, without claiming a clone failed", async () => { + const cwd = await createGitCheckout({ withRemote: false }); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: null, + }, + anchor: { baseCwdFallback: true, materializationFailures: [] }, + })).rejects.toMatchObject({ + code: "workspace_validation_failed", + message: expect.stringContaining("configured project workspace path is not available"), + resultJson: { + workspaceValidation: expect.objectContaining({ + reason: "git_worktree_base_fallback_not_project_workspace", + baseCwdFallback: true, + materializationFailures: [], + }), + }, + }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("allows isolated git worktrees when the anchor reports no fallback", async () => { + const cwd = await createGitCheckout({ withRemote: false }); + try { + await expect(assertGitWorktreeBaseWorkspaceReady({ + requestedExecutionWorkspaceMode: "isolated_workspace", + config: { workspaceStrategy: { type: "git_worktree" } }, + issue: { + id: "issue-1", + identifier: "PAP-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + }, + base: { + baseCwd: cwd, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: "https://github.com/example/repo.git", + repoRef: "origin/master", + }, + anchor: { baseCwdFallback: false, materializationFailures: [] }, + })).resolves.toBeUndefined(); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + it("allows isolated git worktrees when the resolved base is a git checkout", async () => { const cwd = await createGitCheckout({ withRemote: false }); try { @@ -660,6 +815,119 @@ describe("assertGitWorktreeBaseWorkspaceReady", () => { }); }); +describe("scrubGitCredentialText", () => { + it("masks URL userinfo including tokens", () => { + expect(scrubGitCredentialText( + 'fatal: unable to access "https://x-access-token:ghp_abc123@github.com/example/repo.git"', + )).toBe('fatal: unable to access "https://***@github.com/example/repo.git"'); + }); + + it("masks bare-username userinfo and multiple occurrences", () => { + expect(scrubGitCredentialText( + "clone https://alice@github.com/a.git then http://token@internal.example/b.git", + )).toBe("clone https://***@github.com/a.git then http://***@internal.example/b.git"); + }); + + it("masks userinfo on non-HTTP schemes, leaving scp-style remotes alone", () => { + expect(scrubGitCredentialText( + "fatal: cannot clone ssh://deploy:hunter2@internal.example/repo.git or git+ssh://bob@host/x.git", + )).toBe("fatal: cannot clone ssh://***@internal.example/repo.git or git+ssh://***@host/x.git"); + expect(scrubGitCredentialText("fetch from git@github.com:example/repo.git failed")).toBe( + "fetch from git@github.com:example/repo.git failed", + ); + }); + + it("masks entire URL query strings regardless of parameter names", () => { + expect(scrubGitCredentialText( + "fatal: unable to access 'https://github.com/example/repo.git?access_token=ghs_secret&ref=main'", + )).toBe("fatal: unable to access 'https://github.com/example/repo.git?***'"); + expect(scrubGitCredentialText( + "clone https://gitlab.example/repo.git?anything=glpat-123 failed", + )).toBe("clone https://gitlab.example/repo.git?*** failed"); + }); + + it("leaves credential-free text unchanged", () => { + const text = 'Failed to clone "https://github.com/example/repo.git": exit code 128'; + expect(scrubGitCredentialText(text)).toBe(text); + const plain = "git clone failed with exit code=128 at key step ref=main"; + expect(scrubGitCredentialText(plain)).toBe(plain); + }); +}); + +describe("buildAnchorFallbackWorkspaceNotes", () => { + it("reports materialization failures ahead of the generic no-cwd note", () => { + expect(buildAnchorFallbackWorkspaceNotes({ + fallbackCwd: "/paperclip/workspaces/agent-1", + preferredWorkspaceWarning: null, + materializationFailures: [{ + projectWorkspaceId: "workspace-1", + repoUrl: "https://github.com/example/private.git", + error: "fatal: could not read Username", + }], + missingProjectCwds: [], + hasConfiguredProjectCwd: false, + })).toEqual([ + 'Failed to prepare the project workspace checkout: fatal: could not read Username. Using fallback workspace "/paperclip/workspaces/agent-1" for this run.', + ]); + }); + + it("counts additional failed candidates", () => { + const failure = { + projectWorkspaceId: "workspace-1", + repoUrl: null, + error: "clone timed out", + }; + expect(buildAnchorFallbackWorkspaceNotes({ + fallbackCwd: "/fallback", + preferredWorkspaceWarning: null, + materializationFailures: [failure, { ...failure, projectWorkspaceId: "workspace-2" }], + missingProjectCwds: [], + hasConfiguredProjectCwd: false, + })).toEqual([ + 'Failed to prepare the project workspace checkout (clone timed out), and 1 other candidate workspace(s) also failed. Using fallback workspace "/fallback" for this run.', + ]); + }); + + it("preserves the existing missing-path and no-cwd notes when nothing failed to materialize", () => { + expect(buildAnchorFallbackWorkspaceNotes({ + fallbackCwd: "/fallback", + preferredWorkspaceWarning: "Selected project workspace \"workspace-9\" is not available on this project.", + materializationFailures: [], + missingProjectCwds: ["/missing/path"], + hasConfiguredProjectCwd: true, + })).toEqual([ + 'Selected project workspace "workspace-9" is not available on this project.', + 'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.', + ]); + expect(buildAnchorFallbackWorkspaceNotes({ + fallbackCwd: "/fallback", + preferredWorkspaceWarning: null, + materializationFailures: [], + missingProjectCwds: [], + hasConfiguredProjectCwd: false, + })).toEqual([ + 'Project workspace has no local cwd configured. Using fallback workspace "/fallback" for this run.', + ]); + }); + + it("emits both failure and missing-path notes when a project has both kinds of candidates", () => { + expect(buildAnchorFallbackWorkspaceNotes({ + fallbackCwd: "/fallback", + preferredWorkspaceWarning: null, + materializationFailures: [{ + projectWorkspaceId: "workspace-1", + repoUrl: "https://github.com/example/private.git", + error: "authentication failed", + }], + missingProjectCwds: ["/missing/path"], + hasConfiguredProjectCwd: true, + })).toEqual([ + 'Failed to prepare the project workspace checkout: authentication failed. Using fallback workspace "/fallback" for this run.', + 'Project workspace path "/missing/path" is not available yet. Using fallback workspace "/fallback" for this run.', + ]); + }); +}); + describe("assertPushCapabilityCheckoutValid", () => { it("rejects a GitHub PR workflow checkout without a configured push remote", async () => { const cwd = await createGitCheckout({ withRemote: false }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3bb96329dd..ab27ce02b2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1804,6 +1804,15 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: { executionWorkspacePreference?: string | null; } | null; base: ExecutionWorkspaceInput; + /** + * Anchor-resolution facts that `base` alone cannot express: whether the base cwd is the + * agent-home fallback despite the project having workspaces, and which materialization + * attempts failed on the way there. Absent means "not a fallback" (legacy callers). + */ + anchor?: { + baseCwdFallback?: boolean; + materializationFailures?: WorkspaceMaterializationFailure[]; + } | null; }) { if (!input.issue) return; if ( @@ -1849,12 +1858,40 @@ export async function assertGitWorktreeBaseWorkspaceReady(input: { ); } + // Checked before isGitCheckout: when materialization failed and the base cwd is the + // agent-home fallback, a git checkout at that path would be an unrelated repository — + // proceeding would build worktrees off the wrong repo, and failing on the checkout probe + // would mask the real cause (for example a clone that could not authenticate). The reason + // is reserved for genuine materialization failures; a fallback with no failed attempt + // (a configured path that is simply unavailable) keeps its accurate reporting below. + const materializationFailures = input.anchor?.materializationFailures ?? []; + if (input.anchor?.baseCwdFallback && materializationFailures.length > 0) { + const failureDetail = `: ${materializationFailures[0].error.replace(/\s+/g, " ")}`; + fail( + "git_worktree_base_materialization_failed", + `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the project workspace checkout could not be prepared${failureDetail}. Repair the project workspace repository URL, clone access, or configured local cwd, then retry.`, + { baseCwdFallback: true, materializationFailures }, + ); + } + if (!await isGitCheckout(input.base.baseCwd)) { fail( "git_worktree_base_not_git_checkout", `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but base workspace "${input.base.baseCwd}" is not a git checkout. ${remediation}`, ); } + + // A fallback cwd that happens to be a git checkout is still not the configured project + // workspace — building worktrees there would target an unrelated repository. No + // materialization attempt failed here (that case failed above); the configured path is + // simply unavailable, so the message points at the path rather than clone access. + if (input.anchor?.baseCwdFallback) { + fail( + "git_worktree_base_fallback_not_project_workspace", + `Issue ${issueLabel} requested ${input.requestedExecutionWorkspaceMode} with git_worktree, but the configured project workspace path is not available and the fallback cwd "${input.base.baseCwd}" is not the project workspace checkout. Make the configured project workspace path available on this host, or repair the project workspace configuration, then retry.`, + { baseCwdFallback: true, materializationFailures }, + ); + } } export async function assertPushCapabilityCheckoutValid(input: { @@ -2389,6 +2426,33 @@ export type ResolvedAdditionalWorkspace = { repoRef: string | null; }; +/** + * One project-workspace materialization attempt that failed during anchor resolution — for + * example a managed `git clone` that could not authenticate against a private repository. + * Carried on {@link ResolvedWorkspaceForRun} so downstream validation can report the real + * cause instead of the fallback cwd's symptoms. `repoUrl` and `error` are scrubbed of URL + * userinfo credentials before they are stored. + */ +export type WorkspaceMaterializationFailure = { + projectWorkspaceId: string | null; + repoUrl: string | null; + error: string; +}; + +/** + * Mask credential material embedded in URLs so it never reaches warnings, run errors, or + * persisted payloads: userinfo on any scheme (`https://user:token@host`, + * `ssh://user:pass@host`) and the entire query string of any URL (`?access_token=…` and + * every other parameter — masked wholesale rather than by an inevitably incomplete + * parameter-name list). Scp-style remotes (`git@host:path`) carry no password and are left + * alone. + */ +export function scrubGitCredentialText(text: string): string { + return text + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@") + .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s"'?]*)\?[^\s"']*/gi, "$1?***"); +} + export type ResolvedWorkspaceForRun = { cwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -2403,6 +2467,15 @@ export type ResolvedWorkspaceForRun = { repoRef: string | null; }>; warnings: string[]; + /** + * True when project workspaces exist for the run but none could be used, so `cwd` is the + * agent-home fallback rather than a configured or materialized project workspace path. The + * `source` stays `project_primary` in that case (session migration depends on it), so this + * flag is the only reliable fallback signal. + */ + baseCwdFallback: boolean; + /** Failed materialization attempts behind {@link baseCwdFallback}; empty when every candidate resolved or none was attempted. */ + materializationFailures: WorkspaceMaterializationFailure[]; /** * Read-only referenced (mentioned) project workspaces for this run, one per authorized * additional project. The array is empty unless the multi-project workspace-sync flag is on @@ -2424,6 +2497,47 @@ type ResolvedAnchorWorkspaceForRun = Omit< "additionalWorkspaces" | "referencedProjectFailures" >; +/** + * Assemble the run warnings for the agent-home fallback when a project has workspaces but none + * produced a usable cwd. Materialization failures (for example a failed managed clone) take + * priority over the generic "no local cwd configured" note, which previously masked them. + */ +export function buildAnchorFallbackWorkspaceNotes(input: { + fallbackCwd: string; + preferredWorkspaceWarning: string | null; + materializationFailures: WorkspaceMaterializationFailure[]; + missingProjectCwds: string[]; + hasConfiguredProjectCwd: boolean; +}): string[] { + const warnings: string[] = []; + if (input.preferredWorkspaceWarning) { + warnings.push(input.preferredWorkspaceWarning); + } + if (input.materializationFailures.length > 0) { + const first = input.materializationFailures[0]; + const extraFailureCount = input.materializationFailures.length - 1; + warnings.push( + extraFailureCount > 0 + ? `Failed to prepare the project workspace checkout (${first.error}), and ${extraFailureCount} other candidate workspace(s) also failed. Using fallback workspace "${input.fallbackCwd}" for this run.` + : `Failed to prepare the project workspace checkout: ${first.error}. Using fallback workspace "${input.fallbackCwd}" for this run.`, + ); + } + if (input.missingProjectCwds.length > 0) { + const firstMissing = input.missingProjectCwds[0]; + const extraMissingCount = Math.max(0, input.missingProjectCwds.length - 1); + warnings.push( + extraMissingCount > 0 + ? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.` + : `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${input.fallbackCwd}" for this run.`, + ); + } else if (input.materializationFailures.length === 0 && !input.hasConfiguredProjectCwd) { + warnings.push( + `Project workspace has no local cwd configured. Using fallback workspace "${input.fallbackCwd}" for this run.`, + ); + } + return warnings; +} + /** * Build the plural workspace list that a run exposes to the agent through the * `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor @@ -8298,6 +8412,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ? projectWorkspaceRows.find((workspace) => workspace.id === preferredProjectWorkspaceId) ?? null : null; const missingProjectCwds: string[] = []; + const materializationFailures: WorkspaceMaterializationFailure[] = []; let hasConfiguredProjectCwd = false; let preferredWorkspaceWarning: string | null = null; if (preferredProjectWorkspaceId && !preferredWorkspace) { @@ -8317,8 +8432,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) projectCwd = resolvedCwd.cwd; managedWorkspaceWarning = resolvedCwd.warning; } catch (error) { + const scrubbedError = scrubGitCredentialText( + error instanceof Error ? error.message : String(error), + ); + const workspaceRepoUrl = readNonEmptyString(workspace.repoUrl); + materializationFailures.push({ + projectWorkspaceId: workspace.id, + repoUrl: workspaceRepoUrl ? scrubGitCredentialText(workspaceRepoUrl) : null, + error: scrubbedError, + }); if (preferredWorkspace?.id === workspace.id) { - preferredWorkspaceWarning = error instanceof Error ? error.message : String(error); + preferredWorkspaceWarning = scrubbedError; } continue; } @@ -8339,6 +8463,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) warnings: [preferredWorkspaceWarning, managedWorkspaceWarning].filter( (value): value is string => Boolean(value), ), + baseCwdFallback: false, + materializationFailures, }; } if (preferredWorkspace?.id === workspace.id) { @@ -8350,23 +8476,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); await fs.mkdir(fallbackCwd, { recursive: true }); - const warnings: string[] = []; - if (preferredWorkspaceWarning) { - warnings.push(preferredWorkspaceWarning); - } - if (missingProjectCwds.length > 0) { - const firstMissing = missingProjectCwds[0]; - const extraMissingCount = Math.max(0, missingProjectCwds.length - 1); - warnings.push( - extraMissingCount > 0 - ? `Project workspace path "${firstMissing}" and ${extraMissingCount} other configured path(s) are not available yet. Using fallback workspace "${fallbackCwd}" for this run.` - : `Project workspace path "${firstMissing}" is not available yet. Using fallback workspace "${fallbackCwd}" for this run.`, - ); - } else if (!hasConfiguredProjectCwd) { - warnings.push( - `Project workspace has no local cwd configured. Using fallback workspace "${fallbackCwd}" for this run.`, - ); - } + const warnings = buildAnchorFallbackWorkspaceNotes({ + fallbackCwd, + preferredWorkspaceWarning, + materializationFailures, + missingProjectCwds, + hasConfiguredProjectCwd, + }); return { cwd: fallbackCwd, source: "project_primary" as const, @@ -8376,6 +8492,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoRef: projectWorkspaceRows[0]?.repoRef ?? null, workspaceHints, warnings, + baseCwdFallback: true, + materializationFailures, }; } @@ -8394,6 +8512,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoRef: null, workspaceHints, warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [], + baseCwdFallback: false, + materializationFailures: [], }; } @@ -8414,6 +8534,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoRef: readNonEmptyString(previousSessionParams?.repoRef), workspaceHints, warnings: [], + baseCwdFallback: false, + materializationFailures: [], }; } } @@ -8447,6 +8569,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) repoRef: null, workspaceHints, warnings, + baseCwdFallback: false, + materializationFailures: [], }; } @@ -13839,6 +13963,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) config: hostExecutionWorkspaceConfig, issue: issueRef, base: executionWorkspaceBase, + anchor: { + baseCwdFallback: resolvedWorkspace.baseCwdFallback, + materializationFailures: resolvedWorkspace.materializationFailures, + }, }); const workspaceStrategyForFingerprint = parseObject(hostExecutionWorkspaceConfig.workspaceStrategy); const workspaceStrategyFingerprintValue = @@ -15832,9 +15960,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } function buildWorkspaceValidationRecoveryComment(input: { - latestRun: Pick | null | undefined; + latestRun: + | Pick + | null + | undefined; }) { const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); + const validationReason = readNonEmptyString( + readWorkspaceValidationPayloadFromRun(input.latestRun).reason, + ); + if (validationReason === "git_worktree_base_materialization_failed") { + return ( + "Paperclip stopped before launching the local adapter because the project workspace checkout could not be prepared " + + `(for example the repository clone failed).${failureSummary ?? ""} ` + + "Moving it to `blocked` with a source-scoped recovery action so the repository URL, clone access, or configured local cwd can be repaired before resuming." + ); + } return ( "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " + `This prevents git-sensitive adapters from running in an unrelated fallback cwd.${failureSummary ?? ""} ` + diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index fbeb4aacb9..f9545fa66b 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2928,7 +2928,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) : recoveryCause === "workspace_validation_failed" ? readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_branch_incoherence" ? "Repair the source issue git worktree branch incoherence, or choose a new execution workspace, before resuming adapter execution." - : "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution." + : readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_base_materialization_failed" + ? "Repair the project workspace repository URL or clone access, or configure a local checkout cwd, before resuming adapter execution." + : "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution." : recoveryCause === "configuration_incomplete" ? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution." : recoveryCause === "execution_review_participant_recovery"