diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 1ae9fe7c1db..ec515dba083 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1534,6 +1534,17 @@ a bordered container with Retry for a failed or timed-out run. A failed Retry shows its error in the same container. New user messages and saved undelivered messages pass normal admission independently of automatic recovery exhaustion. +When preparation fails because a shared repository retains an unreadable task +workspace, the task offers Retry. Resolving that recovery schedules an isolated +repository selection with a fresh task-owned branch only when the latest failed run records bootstrap failure +with no provider work. Normal admission applies the selection, resets the old +session, and checks the configured remote with the current Git identity. It +preserves the task's agent, model, environment, and execution gates. The previous +workspace remains retained; its local files and Git objects are neither copied +nor deleted. The Retry control stays above the composer even when the original notice is +outside the loaded conversation. The user does not need to select a workspace +or grant access to the other task. An explicit pending workspace choice takes precedence over recovery. + ### Operator identity and permission for manual dispatch A legacy queued-message Interrupt is a new instruction from the user who clicks diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 280187e393e..7eeb62a1e3a 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -56,7 +56,7 @@ Fresh standard-trust tasks with an implicit repository default can use an owned remote clone when the shared default source fails the restricted-task workspace guard. This does not grant access to the private local checkout or its Git metadata. The normal admitted Git identity must already authorize the configured remote. -Creation of a fallback source is unavailable for explicit, parent/channel, reused +Creation of an automatic fallback source is unavailable for explicit, parent/channel, reused or low-trust selections, or branch templates without a task-derived placeholder; an existing verified owned source remains reusable. Literal branch selections retain the original denial without cloning or replacing their unpublished commits. @@ -64,11 +64,19 @@ New implicit Git defaults record project-source provenance. Untyped explicit, inherited and older ambiguous checkout bindings retain the denial; admission reads the current company-scoped binding before considering the fallback. A retained denial blocks the task as `configuration_incomplete` -(`workspace_source_restricted`) before dispatch; it grants no access and names the -owner action on the task. +(`workspace_source_restricted`) before dispatch; it grants no access and offers Retry. The same blocker applies when shared project workspace repository admission finds the restricted source. The existing binding and files stay intact; no provider starts and no automatic failure retry is queued. +An authorized board recovery through `POST /api/issues/{id}/recovery-actions/resolve` +can schedule a server-owned `recovery` selection for the latest restricted-source +failure only when its saved evidence proves provider work never started. The +selection requires task mutation and configured-source access and applies at normal +admission. It keeps task environment settings and never reads or deletes the denied +checkout. Pause, budget, approval, interaction, dependency, and ownership gates +remain effective. Repeated resolution of the same action does not enqueue another +run. Agents cannot mint recovery provenance through the workspace selection API; +they can observe the existing recovery action and request board recovery. Local paths, unsupported transports and credential-bearing URLs are not fallback sources. Company/task/remote bindings, real paths, Git storage and the exact origin are revalidated for reuse, cleanup and reopening. A failed save removes only the diff --git a/maps/feature-map/workspaces.md b/maps/feature-map/workspaces.md index 348375eb6d3..5380d9af9a4 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -76,9 +76,17 @@ remote credentials or a provider deployment. For a task reusing the shared project checkout, add a private task worktree that the assigned agent cannot read. Continue the shared task and verify the -**Workspace source restricted** notice names a workspace repair action. No agent -turn or automatic failure retry may start. Preserve the task binding and both -tasks' files. Repeat with a reused Git worktree to check the same blocker. +task offers **Retry**, without a workspace picker or repair checklist. Before the +click, no agent turn or automatic failure retry may start. Click Retry and verify +that normal admission selects an isolated checkout from the configured remote, +preserves the agent and environment, and leaves both tasks' old files intact. +Private local branches and commits must not enter the new checkout. Repeated +clicks request one retry; a paused project or pending approval must still block it. +Repeat with fixed project branches, an existing task branch, and adapter branch +overrides; Retry must choose a fresh branch while preserving setup and model settings. +Add enough later comments to page the original notice out of view, reload, and +verify Retry remains above the composer. This control does +not depend on the workspace picker's visibility setting. For reconstructable Git expiration, finish a disposable task tree, run the internal lifecycle sweep after its 24-hour grace, and inspect candidate/reclaimed diff --git a/packages/shared/src/types/task-workspace.ts b/packages/shared/src/types/task-workspace.ts index 0938a8dca28..a23afa13d2c 100644 --- a/packages/shared/src/types/task-workspace.ts +++ b/packages/shared/src/types/task-workspace.ts @@ -6,7 +6,7 @@ export type TaskWorkspaceIntent = { version: 1; request?: { key: string; expectedBindingRevision: number }; selection: TaskWorkspaceSelection; - source: "explicit" | "parent" | "channel" | "project" | "operator_cwd" | "task_default" | "legacy"; + source: "explicit" | "parent" | "channel" | "project" | "operator_cwd" | "task_default" | "legacy" | "recovery"; }; export type TaskWorkspacePendingSelection = { version: 1; diff --git a/packages/shared/src/validators/task-workspace.ts b/packages/shared/src/validators/task-workspace.ts index c2b8fe7ad16..28774b510b3 100644 --- a/packages/shared/src/validators/task-workspace.ts +++ b/packages/shared/src/validators/task-workspace.ts @@ -6,7 +6,7 @@ export const taskWorkspaceSelectionSchema = z.discriminatedUnion("kind", [ ]); export const taskWorkspaceIntentSchema = z.object({ version: z.literal(1), request: z.object({ key: z.string().min(1).max(200), expectedBindingRevision: z.number().int().nonnegative() }).strict().optional(), selection: taskWorkspaceSelectionSchema, - source: z.enum(["explicit", "parent", "channel", "project", "operator_cwd", "task_default", "legacy"]), + source: z.enum(["explicit", "parent", "channel", "project", "operator_cwd", "task_default", "legacy", "recovery"]), }).strict(); export const selectTaskWorkspaceSchema = z.object({ selection: taskWorkspaceSelectionSchema, diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index bf3931046ee..d2d32eb3d45 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -1109,11 +1109,11 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId)); expect(issue).toMatchObject({ status: "blocked", executionRunId: null, executionWorkspaceId: seeded.executionWorkspaceId }); const comments = await db.select({ body: issueComments.body }).from(issueComments).where(eq(issueComments.issueId, seeded.issueId)); - expect(comments.map(comment => comment.body).join("\n")).toContain("shared project checkout that also holds another task's workspace"); + expect(comments.map(comment => comment.body).join("\n")).toContain("The task couldn’t start."); const actions = await db.select({ nextAction: issueRecoveryActions.nextAction }).from(issueRecoveryActions) .where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId)); expect(actions).toHaveLength(1); - expect(actions.map(action => action.nextAction).join("\n")).toContain("select a new workspace"); + expect(actions.map(action => action.nextAction).join("\n")).toContain("Retry the task."); expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(1); expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1b1cf2ef87f..967814db0e7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,5 @@ import { parseObject } from "../adapters/utils.js"; +import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js"; import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; import { listIssueSummaries } from "../services/issue-summaries.js"; import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; @@ -9713,6 +9714,10 @@ export function issueRoutes( { source: "recovery_action_resolution" }, ); + const workspaceRetry = outcome === "restored" && sourceIssueStatus === "todo" + ? await prepareRestrictedWorkspaceRetry(tx as unknown as Db, lockedIssue, activeRecoveryAction) + : null; + if (workspaceRetry) assertBoard(req); let repairedWorkspaceSettings: Record | undefined; if (workspaceBaseRef) { const currentSettings = parseObject(lockedIssue.executionWorkspaceSettings); @@ -9777,12 +9782,12 @@ export function issueRoutes( if ( outcome === "restored" && sourceIssueStatus === "todo" && - (activeRecoveryAction.kind === "deliberate_wait_without_target" || workspaceBaseRef) + (activeRecoveryAction.kind === "deliberate_wait_without_target" || workspaceBaseRef || workspaceRetry) ) { if ( lockedIssue.status !== "blocked" || activeRecoveryAction.ownerType !== "board" || - (!workspaceBaseRef && activeRecoveryAction.wakePolicy?.type !== "board_escalation") || + (!workspaceBaseRef && !workspaceRetry && activeRecoveryAction.wakePolicy?.type !== "board_escalation") || !activeRecoveryAction.returnOwnerAgentId || lockedIssue.assigneeAgentId !== activeRecoveryAction.returnOwnerAgentId ) { @@ -9949,6 +9954,14 @@ export function issueRoutes( await requireRecoverySourceMutationAuthority(req, lockedIssue); } + if (workspaceRetry?.selection) { + await executionWorkspaceServiceDirect(tx as unknown as Db).selectTaskWorkspace({ + companyId: lockedIssue.companyId, issueId: lockedIssue.id, actor: req.actor, + selection: workspaceRetry.selection, expectedBindingRevision: lockedIssue.workspaceBindingRevision, + requestKey: activeRecoveryAction.id, source: "recovery", + }); + } + if ( lockedIssue.status === "in_review" && (sourceIssueStatus === "done" || diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index a4b56ce9e04..b31c561f4ca 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1386,7 +1386,7 @@ const WORKSPACE_COMPACTION_TRASH_STALE_MS = 60 * 60 * 1000; const WORKSPACE_COMPACTION_TRASH_PURGE_INTERVAL_MS = 30 * 60 * 1000; export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { - async function resolveSourceWorkspaceStrategy(input: { + async function resolveSourceWorkspaceConfiguration(input: { companyId: string; issueId?: string | null; assigneeAgentId?: string | null; assigneeAdapterOverrides?: unknown; executionWorkspaceSettings?: unknown; executionWorkspacePolicy: unknown; typedSelection?: boolean; projectId?: string | null; executionPolicy?: unknown; @@ -1417,7 +1417,11 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const mode = trust.kind === "low_trust_review" && resolvedMode === "shared_workspace" ? "isolated_workspace" : resolvedMode; const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: parseObject(agent?.adapterConfig), projectPolicy, issueSettings, mode, legacyUseProjectWorkspace: overrides?.useProjectWorkspace ?? null, adapterConfigOverrides: overrides?.adapterConfig }); - return resolveEffectiveWorkspaceStrategyType(mode, config); + return { strategy: resolveEffectiveWorkspaceStrategyType(mode, config), config }; + } + + async function resolveSourceWorkspaceStrategy(input: Parameters[0], reader: Db | DbTransaction = db) { + return (await resolveSourceWorkspaceConfiguration(input, reader)).strategy; } async function validateSelection(input: { taskCreation?: TaskWorkspaceCreationTrustContext; companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; assigneeAdapterOverrides?: unknown; projectId?: string | null; executionPolicy?: unknown; preserveImplicitDefaultSettings?: boolean }, reader: Db | DbTransaction = db) { @@ -1480,7 +1484,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } async function selectTaskWorkspace(input: { companyId: string; issueId: string; actor: AuthorizationActor; - selection: TaskWorkspaceSelection; expectedBindingRevision: number; requestKey: string }) { + selection: TaskWorkspaceSelection; expectedBindingRevision: number; requestKey: string; source?: "recovery" }) { + if (input.source === "recovery" && input.actor.type !== "board") throw forbidden("Workspace recovery requires a board actor"); return db.transaction(async tx => { const [task] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("update"); @@ -1506,7 +1511,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } if (task.workspaceBindingRevision !== input.expectedBindingRevision) throw conflict("Task workspace binding changed; inspect it before selecting again"); await validateSelection(input, tx); - const intent: TaskWorkspaceIntent = { version: 1, request: { key: input.requestKey, expectedBindingRevision: input.expectedBindingRevision }, selection: input.selection, source: "explicit" }; + const intent: TaskWorkspaceIntent = { version: 1, request: { key: input.requestKey, expectedBindingRevision: input.expectedBindingRevision }, selection: input.selection, source: input.source ?? "explicit" }; const nextRevision = task.workspaceBindingRevision + 1; await tx.update(issues).set({ workspaceBindingRevision: nextRevision, workspacePendingSelection: { version: 1, requestKey: input.requestKey, @@ -1531,7 +1536,27 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const unsettled = await hasPendingTaskWorkspaceRecovery(tx, { ...input, excludeRunId: input.runId }); if (unsettled) throw conflict("Previous task run still owns workspace recovery; finish recovery before changing its workspace"); const patch = await validateSelection({ ...input, selection: pending.intent.selection }, tx); - await tx.update(issues).set({ ...patch, workspaceSelection: pending.intent, workspacePendingSelection: null, + let assigneeAdapterOverrides = task.assigneeAdapterOverrides; + if (pending.intent.source === "recovery") { + patch.executionWorkspaceSettings = { ...parseObject(task.executionWorkspaceSettings), ...patch.executionWorkspaceSettings }; + const [source] = await tx.select({ policy: projects.executionWorkspacePolicy }).from(projectWorkspaces) + .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)).where(and( + eq(projectWorkspaces.companyId, input.companyId), eq(projectWorkspaces.id, patch.projectWorkspaceId!), + )); + const { strategy, config } = await resolveSourceWorkspaceConfiguration({ ...input, typedSelection: true, + executionWorkspacePolicy: source?.policy, executionWorkspaceSettings: patch.executionWorkspaceSettings }, tx); + if (strategy === "git_worktree") { + const workspaceStrategy = { ...parseObject(config.workspaceStrategy), type: "git_worktree", + existingBranch: null, branchTemplate: "retry-{{issue.id}}" }; + patch.executionWorkspaceSettings = { ...patch.executionWorkspaceSettings, workspaceStrategy }; + const adapterConfig = parseObject(assigneeAdapterOverrides?.adapterConfig); + if (adapterConfig.workspaceStrategy) { + assigneeAdapterOverrides = { ...assigneeAdapterOverrides, adapterConfig: { ...adapterConfig, + workspaceStrategy } }; + } + } + } + await tx.update(issues).set({ ...patch, assigneeAdapterOverrides, workspaceSelection: pending.intent, workspacePendingSelection: null, workspaceBindingRevision: task.workspaceBindingRevision + 1, updatedAt: new Date() }).where(eq(issues.id, task.id)); return true; }); diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 7e4d502e684..6a45f2c95ec 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -26,6 +26,15 @@ import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js"; import { issueService } from "../issues.js"; import { trace } from "@opentelemetry/api"; +import { buildExecutionWorkspaceAdapterConfig } from "../execution-workspace-policy.js"; +import { prepareRestrictedWorkspaceRetry } from "../restricted-workspace-retry.js"; +import express from "express"; +import request from "supertest"; +import { issueRoutes } from "../../routes/issues.js"; +import { errorHandler } from "../../middleware/error-handler.js"; +import { issueRecoveryActionService } from "../issue-recovery-actions.js"; +import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js"; +import { createStorageService } from "../../storage/service.js"; import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace"; const execFile = promisify(execFileCallback); @@ -206,7 +215,7 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", } }); - it.each(["create", "failed-save", "shared-retry-after-failed-save", "literal-branch", "legacy-explicit-source", "inherited-source", "legacy-untyped-source", "changed-source-intent", "context-source"] as const)("preserves restricted source isolation during %s", async scenario => { + it.each(["create", "retry-restricted", "retry-fixed-branch", "retry-existing-branch", "retry-adapter-branch", "failed-save", "shared-retry-after-failed-save", "literal-branch", "legacy-explicit-source", "inherited-source", "legacy-untyped-source", "changed-source-intent", "context-source"] as const)("preserves restricted source isolation during %s", async scenario => { const { input, source, issue: originalIssue } = await fixture(true); await db.update(companies).set({ issueCounter: 1 }).where(eq(companies.id, originalIssue.companyId)); const issue = await issueService(db).create(originalIssue.companyId, { @@ -260,6 +269,73 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", return { cwd, warning: null }; }); const isolatedSource = isolatedRemoteSourcePath(issue.companyId, issue.id); + if (scenario.startsWith("retry-")) { + const fixedBranch = scenario === "retry-fixed-branch"; + const existingBranch = scenario === "retry-existing-branch"; + const projectPolicy = { enabled: true, defaultMode: "isolated_workspace" as const, + workspaceStrategy: { type: "git_worktree" as const, baseRef: "main", provisionCommand: "true", + ...(fixedBranch ? { branchTemplate: "fixed-project-branch" } : {}) } }; + await db.update(projects).set({ executionWorkspacePolicy: projectPolicy }).where(eq(projects.id, issue.projectId!)); + input.policy.projectExecutionWorkspacePolicy = projectPolicy; + const [oldWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId, + projectId: issue.projectId, projectWorkspaceId: issue.projectWorkspaceId, sourceIssueId: issue.id, + name: "Original files", cwd: source, repoUrl: remoteUrl, mode: "shared_workspace", + strategyType: "project_primary", providerType: "local", status: "idle" }).returning(); + await writeFile(join(source, "unfinished.txt"), "Keep my original files\n"); + await db.update(heartbeatRuns).set({ status: "failed", contextSnapshot: { issueId: issue.id, projectId: issue.projectId }, + resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }).where(eq(heartbeatRuns.id, run.id)); + await db.update(issues).set({ status: "blocked", executionWorkspaceId: oldWorkspace.id, executionWorkspacePreference: "reuse_existing", + assigneeAdapterOverrides: scenario === "retry-adapter-branch" ? { adapterConfig: { model: "preserved-model", workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "fixed-adapter-branch" } } } : null, + executionWorkspaceSettings: { mode: "shared_workspace", environmentId: input.environment.localEnvironment.id, + ...(existingBranch ? { workspaceStrategy: { type: "git_worktree", baseRef: "main", existingBranch: "restricted-task" } } : {}) }, + }).where(eq(issues.id, issue.id)); + const service = executionWorkspaceService(db); + const actor = { type: "board" as const, source: "local_implicit" as const }; + const action = await issueRecoveryActionService(db).upsertSourceScoped({ + companyId: issue.companyId, sourceIssueId: issue.id, kind: "configuration_validation", + ownerType: "board", returnOwnerAgentId: input.agent.id, cause: "configuration_incomplete", + fingerprint: `restricted:${run.id}`, evidence: { latestRunId: run.id }, nextAction: "Retry the task.", + }); + const retryPlan = await prepareRestrictedWorkspaceRetry(db, await readIssue(issue.id), action); + expect(retryPlan?.selection).toEqual({ kind: "configured_source", projectWorkspaceId: issue.projectWorkspaceId!, mode: "managed_isolated" }); + const enqueue = vi.fn(async () => null); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", issueRoutes(db, createStorageService(createLocalDiskStorageProvider(join(home, "storage"))), { + recoveryActionEnqueueWakeup: enqueue, + })); + app.use(errorHandler); + const retry = () => request(app).post(`/api/issues/${issue.id}/recovery-actions/resolve`) + .send({ actionId: action.id, outcome: "restored", sourceIssueStatus: "todo" }); + const failedResult = (await readRun(run.id)).resultJson!; + await db.update(heartbeatRuns).set({ resultJson: { ...failedResult, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } }) + .where(eq(heartbeatRuns.id, run.id)); + expect((await retry()).status).toBe(409); + expect((await readIssue(issue.id)).workspacePendingSelection).toBeNull(); + expect(enqueue).not.toHaveBeenCalled(); + await db.update(heartbeatRuns).set({ resultJson: failedResult }).where(eq(heartbeatRuns.id, run.id)); + await db.update(projects).set({ pausedAt: new Date() }).where(eq(projects.id, issue.projectId!)); + expect((await retry()).status).toBe(409); + expect((await readIssue(issue.id)).workspacePendingSelection).toBeNull(); + expect(enqueue).not.toHaveBeenCalled(); + await db.update(projects).set({ pausedAt: null }).where(eq(projects.id, issue.projectId!)); + const responses = await Promise.all([retry(), retry()]); + for (const response of responses) expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(enqueue).toHaveBeenCalledOnce(); + expect((await readIssue(issue.id)).workspacePendingSelection?.intent.source).toBe("recovery"); + expect((await readIssue(issue.id)).executionWorkspaceId).toBe(oldWorkspace.id); + await service.applyPendingTaskWorkspaceSelection({ companyId: issue.companyId, issueId: issue.id, runId: run.id, actor }); + const selected = await readIssue(issue.id); + input.task.issueRef = selected; + input.task.workspaceSelection = selected.workspaceSelection; + input.policy.issueExecutionWorkspaceSettings = selected.executionWorkspaceSettings; + input.config.mergedConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, + issueSettings: selected.executionWorkspaceSettings, mode: "isolated_workspace", legacyUseProjectWorkspace: null, + adapterConfigOverrides: selected.assigneeAdapterOverrides?.adapterConfig as Record | undefined }); + if (scenario === "retry-adapter-branch") expect(input.config.mergedConfig.model).toBe("preserved-model"); + } if (["literal-branch", "legacy-explicit-source", "inherited-source", "legacy-untyped-source", "changed-source-intent", "context-source"].includes(scenario)) { const selectedBranch = scenario === "literal-branch" ? "feature/literal-pin" : `test-${issue.identifier}`; await execFile("git", ["-C", source, "checkout", "-b", selectedBranch]); @@ -317,6 +393,11 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", expect(await readFile(join(result.executionWorkspace.cwd, "README.md"), "utf8")).toBe("source content\n"); await expect(access(join(result.executionWorkspace.cwd, "private-work"))).rejects.toMatchObject({ code: "ENOENT" }); expect(clone).toHaveBeenCalledOnce(); + if (scenario.startsWith("retry-")) { + expect((await readIssue(issue.id)).executionWorkspaceSettings).toMatchObject({ mode: "isolated_workspace", environmentId: input.environment.localEnvironment.id }); + expect(await readFile(join(source, "unfinished.txt"), "utf8")).toBe("Keep my original files\n"); + await expect(access(join(result.executionWorkspace.cwd, "unfinished.txt"))).rejects.toMatchObject({ code: "ENOENT" }); + } await expect(execFile("git", ["-C", isolatedSource, "cat-file", "-e", privateCommit])).rejects.toBeDefined(); await expect(execFile("git", ["-C", isolatedSource, "rev-parse", "--verify", "refs/heads/restricted-task"])).rejects.toBeDefined(); expect(await realpath(join(isolatedSource, ".git"))).not.toBe(await realpath(join(source, ".git"))); diff --git a/server/src/services/isolated-remote-workspace-source.ts b/server/src/services/isolated-remote-workspace-source.ts index ac5f7ee5be6..db50622f63f 100644 --- a/server/src/services/isolated-remote-workspace-source.ts +++ b/server/src/services/isolated-remote-workspace-source.ts @@ -94,7 +94,7 @@ export function canIsolateRemoteWorkspaceSource(input: { && !/\{\{\s*(?:issue\.(?:id|identifier|title)|slug)\s*\}\}/.test(branchTemplate); if (input.trustPresetKind !== "standard" || !isRestrictedWorkspaceSourceDenial(input.error) || input.reuseExisting || input.hasExistingWorkspace || input.strategy.existingBranch || selectsFixedBranch - || (input.selection && !["project", "task_default"].includes(input.selection.source)) + || (input.selection && !["project", "task_default", "recovery"].includes(input.selection.source)) || (input.selectedProjectWorkspaceId && (input.selection?.selection.kind !== "configured_source" || input.selection.selection.projectWorkspaceId !== input.selectedProjectWorkspaceId))) return false; if (!input.repoUrl) return false; diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index e0264f92ee5..4f1849d7d8a 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2597,7 +2597,7 @@ export function recoveryService( : readConfigurationIncompletePayload(input.latestRun)?.reason === "ai_connection_unavailable" ? "Reconnect the selected AI account or choose an available connection, then continue the task." : readConfigurationIncompletePayload(input.latestRun)?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON - ? "Push any unpublished work from this task's workspace, then select a new workspace for the task, or have an owner grant access to the restricted task. Then retry the original assignee." + ? "Retry the task." : readConfigurationIncompletePayload(input.latestRun) ?.reason === SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON ? `Board operator: the sandbox provider plugin named in the run failure is not ready; ${sandboxProviderPluginRemedy( diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts index 594bff88519..7cf6541d25d 100644 --- a/server/src/services/recovery/stranded-notice.test.ts +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -76,12 +76,11 @@ describe("stranded recovery notice seeds", () => { expect(seed.nextAction).toContain("stop it if it is stuck"); }); - it("explains a shared checkout restricted by another task instead of missing secrets", () => { + it("offers retry without a workspace repair checklist", () => { const seed = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "workspace_source_restricted" }); - expect(seed.title).toBe("Workspace source restricted"); - expect(seed.body).toContain("shared project checkout"); - expect(seed.body).not.toContain("secret/env bindings"); - expect(seed.nextAction).toContain("select a new workspace"); + expect(seed.title).toBe("Task couldn’t start"); + expect(seed.body).toBe("The task couldn’t start."); + expect(seed.nextAction).toBe("Retry the task."); }); it("keeps the secret-binding copy for other configuration gaps", () => { diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts index d4a1014cd44..101114042f6 100644 --- a/server/src/services/recovery/stranded-notice.ts +++ b/server/src/services/recovery/stranded-notice.ts @@ -139,9 +139,9 @@ export function buildConfigurationIncompleteRecoveryNoticeSeed( } if (readNonEmptyStringField(configurationIncomplete, "reason") === "workspace_source_restricted") { return { - title: "Workspace source restricted", - body: "Paperclip stopped before the agent started because this task's workspace uses a shared project checkout that also holds another task's workspace, and this run cannot read that task. Paperclip does not copy a shared checkout with restricted files.", - nextAction: "Push any unpublished work from this task's workspace, then select a new workspace for the task. Alternatively, have an owner grant access to the restricted task. Then retry the task.", + title: "Task couldn’t start", + body: "The task couldn’t start.", + nextAction: "Retry the task.", tone: "danger", }; } diff --git a/server/src/services/restricted-workspace-retry.ts b/server/src/services/restricted-workspace-retry.ts new file mode 100644 index 00000000000..ef1cf10985d --- /dev/null +++ b/server/src/services/restricted-workspace-retry.ts @@ -0,0 +1,46 @@ +import { heartbeatRuns, issues, projectWorkspaces, type Db } from "@paperclipai/db"; +import { isUuidLike, type IssueRecoveryAction, type TaskWorkspaceSelection } from "@paperclipai/shared"; +import { and, desc, eq, or, sql } from "drizzle-orm"; +import { parseObject } from "../adapters/utils.js"; +import { conflict } from "../errors.js"; +import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.js"; + +export async function prepareRestrictedWorkspaceRetry(db: Db, issue: typeof issues.$inferSelect, + action: IssueRecoveryAction): Promise<{ selection: TaskWorkspaceSelection | null } | null> { + const sourceRunId = action.evidence?.latestRunId ?? action.evidence?.runId; + if (action.ownerType !== "board" || typeof sourceRunId !== "string" || !isUuidLike(sourceRunId)) return null; + const [run] = await db.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, + contextSnapshot: heartbeatRuns.contextSnapshot, resultJson: heartbeatRuns.resultJson, + errorCode: heartbeatRuns.errorCode, error: heartbeatRuns.error, + }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.id, sourceRunId), + )); + if (!run) return null; + const result = parseObject(run.resultJson); + const restricted = parseObject(result.configurationIncomplete).reason === RESTRICTED_WORKSPACE_SOURCE_REASON + || (run.errorCode === "setup_failed" && run.error === "Task workspace access is no longer available"); + if (!restricted) return null; + const context = parseObject(run.contextSnapshot); + const execution = parseObject(result.executionRecovery); + if (issue.status !== "blocked" || action.status !== "active" || action.ownerType !== "board" + || !issue.assigneeAgentId || issue.assigneeAgentId !== action.returnOwnerAgentId + || run.agentId !== issue.assigneeAgentId || run.status !== "failed" + || context.issueId !== issue.id || (context.projectId ?? null) !== issue.projectId + || execution.kind !== "bootstrap" || execution.providerWorkStarted !== false) { + throw conflict("This retry no longer matches the task. Refresh and try again."); + } + const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, issue.companyId), + or(eq(heartbeatRuns.issueId, issue.id), eq(heartbeatRuns.nativeIssueId, issue.id), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`), + )).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1); + if (latest?.id !== run.id) throw conflict("A newer attempt exists. Refresh and try again."); + if (issue.workspacePendingSelection) return { selection: null }; + const [source] = await db.select({ id: projectWorkspaces.id, repoUrl: projectWorkspaces.repoUrl }) + .from(projectWorkspaces).where(and(eq(projectWorkspaces.companyId, issue.companyId), + issue.projectWorkspaceId ? eq(projectWorkspaces.id, issue.projectWorkspaceId) + : issue.projectId ? and(eq(projectWorkspaces.projectId, issue.projectId), eq(projectWorkspaces.isPrimary, true)) : sql`false`, + )).limit(1); + if (!source?.repoUrl) throw conflict("The task’s repository is unavailable. Retry after reconnecting it."); + return { selection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "managed_isolated" } }; +} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 695d9a168ed..0986dfdb6f1 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,5 +1,5 @@ import { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author"; -import { useWorkspaceBaseRefRecovery } from "./WorkspaceBaseRefRecovery"; +import { useCurrentWorkspaceRetry, useWorkspaceBaseRefRecovery, useWorkspaceRetry } from "./WorkspaceBaseRefRecovery"; import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice"; import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; @@ -3500,6 +3500,7 @@ function SystemNoticeCommentContent({ : null; const recoverySnapshot = useDispositionRecoverySnapshot(commentMetadata); const branchRecovery = useWorkspaceBaseRefRecovery(commentMetadata); + const workspaceRetry = useWorkspaceRetry(commentMetadata); const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const runId = typeof custom.runId === "string" ? custom.runId : null; @@ -3596,6 +3597,7 @@ function SystemNoticeCommentContent({ }); }; + if (authorType === "system" && workspaceRetry) return null; if (authorType === "system" && branchRecovery) return
; if (authorType === "system" && recoverySnapshot) { @@ -5847,6 +5849,7 @@ export function IssueChatThread({ externalReferences, linkCaseReferences = false, }: IssueChatThreadProps) { + const workspaceRetry = useCurrentWorkspaceRetry(); const location = useLocation(); const lastScrolledHashRef = useRef(null); const didInitialHashScrollDecisionRef = useRef(false); @@ -6634,7 +6637,7 @@ export function IssueChatThread({ onResume={onResumeFromBacklog} resuming={resumeFromBacklogPending} /> - {recoveryAction ? ( + {recoveryAction && !workspaceRetry ? ( ({ get: vi.fn(), resolve: vi.fn() })); @@ -17,6 +18,8 @@ const issue = { id: "issue", companyId: "company", status: "blocked", assigneeAg const metadata: IssueCommentMetadata = { version: 1, sourceRunId: "run", sections: [{ rows: [{ type: "key_value", label: "Recovery action", value: "action" }] }] }; function Consumer({ data = metadata }: { data?: IssueCommentMetadata }) { const value = useWorkspaceBaseRefRecovery(data); + const retry = useCurrentWorkspaceRetry(); + if (retry) return ; return value ? :

No repair

; } let container: HTMLDivElement, root: Root, client: QueryClient; @@ -64,3 +67,41 @@ it("renders only the latest run notice when a repeated failure reuses the incide expect(mocks.get).toHaveBeenCalledWith("run"); expect(container.textContent).toContain("Use master & retry"); }); + +it.each(["configuration_incomplete", "setup_failed"])("offers one Retry without workspace explanations for %s", async errorCode => { + mocks.get.mockResolvedValue({ companyId: "company", agentId: "agent", status: "failed", errorCode, + error: "Task workspace access is no longer available", contextSnapshot: { issueId: "issue" }, + resultJson: { configurationIncomplete: errorCode === "configuration_incomplete" ? { reason: "workspace_source_restricted" } : undefined, + executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }); + const action = issue.activeRecoveryAction!; + action.ownerType = "board"; + let finish!: () => void; + mocks.resolve.mockImplementation(() => new Promise(resolve => { finish = () => resolve({ issue: { ...issue, status: "todo" } }); })); + await render({ ...metadata, sourceRunId: "earlier-failure-of-same-action" }); + expect(container.textContent).toBe("Retry"); + const button = container.querySelector("button")!; + await act(async () => { button.click(); button.click(); }); + expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith("issue", { actionId: "action", outcome: "restored", sourceIssueStatus: "todo" }); + expect(button.disabled).toBe(true); + await act(async () => finish()); + expect(container.textContent).toBe("Retrying…"); +}); + +it("keeps retry errors actionable and allows another click", async () => { + mocks.get.mockResolvedValue({ companyId: "company", agentId: "agent", status: "failed", contextSnapshot: { issueId: "issue" }, + resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }); + issue.activeRecoveryAction!.ownerType = "board"; + mocks.resolve.mockRejectedValueOnce(new Error("The task is paused.")); + await render(); + await act(async () => container.querySelector("button")!.click()); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("The task is paused."); + expect(container.querySelector("button")!.disabled).toBe(false); +}); + +it("offers Retry even when its old notice is outside the loaded conversation", async () => { + mocks.get.mockResolvedValue({ companyId: "company", agentId: "agent", status: "failed", contextSnapshot: { issueId: "issue" }, + resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } }); + issue.activeRecoveryAction!.ownerType = "board"; + await render({ version: 1, sections: [] }); + expect(container.textContent).toBe("Retry"); +}); diff --git a/ui/src/components/WorkspaceBaseRefRecovery.tsx b/ui/src/components/WorkspaceBaseRefRecovery.tsx index 7c3e11935e9..83fafdba766 100644 --- a/ui/src/components/WorkspaceBaseRefRecovery.tsx +++ b/ui/src/components/WorkspaceBaseRefRecovery.tsx @@ -11,6 +11,18 @@ function record(value: unknown): Record { type RecoveryValue = { actionId: string; runId: string; props: WorkspaceBaseRefRecoveryNoticeProps }; const Context = createContext(null); +type RetryValue = { actionId: string; runId: string; unavailableReason: string | null; onRetry: () => Promise }; +const RetryContext = createContext(null); + +export function useCurrentWorkspaceRetry() { + return useContext(RetryContext); +} + +export function useWorkspaceRetry(metadata: IssueCommentMetadata | null | undefined) { + const value = useCurrentWorkspaceRetry(); + return value && metadata?.sections.some(section => section.rows.some(row => row.type === "key_value" && row.value === value.actionId)) + ? value : null; +} /** Bind the repair to the server-owned action and its latest failed run. */ export function useWorkspaceBaseRefRecovery(metadata: IssueCommentMetadata | null | undefined) { @@ -20,21 +32,48 @@ export function useWorkspaceBaseRefRecovery(metadata: IssueCommentMetadata | nul ? value : null; } -export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableReason, onRepaired, children }: { +export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableReason, baseRefUnavailableReason, onRepaired, children }: { issue: Issue; agentMap: ReadonlyMap>; unavailableReason: string | null; + baseRefUnavailableReason?: string | null; onRepaired: () => void; children: ReactNode; }) { const action = issue.activeRecoveryAction; - const runId = action?.cause === "configuration_incomplete" && typeof action.evidence.latestRunId === "string" - ? action.evidence.latestRunId : null; + const recordedRunId = action?.evidence?.latestRunId ?? action?.evidence?.runId; + const runId = typeof recordedRunId === "string" ? recordedRunId : null; const { data: run } = useQuery({ queryKey: ["workspace-base-ref-recovery", issue.companyId, issue.id, runId], queryFn: () => heartbeatsApi.get(runId!), enabled: Boolean(runId), staleTime: Infinity, retry: false, }); const failure = record(record(run?.resultJson).configurationIncomplete); + const execution = record(record(run?.resultJson).executionRecovery); + let retry: RetryValue | null = null; + if (action && runId && run?.status === "failed" && run.companyId === issue.companyId + && record(run.contextSnapshot).issueId === issue.id && execution.kind === "bootstrap" && execution.providerWorkStarted === false + && (failure.reason === "workspace_source_restricted" + || (run.errorCode === "setup_failed" && run.error === "Task workspace access is no longer available"))) { + const agent = issue.assigneeAgentId ? agentMap.get(issue.assigneeAgentId) : undefined; + const stale = action.status !== "active" || issue.status !== "blocked" || action.ownerType !== "board" + || !issue.assigneeAgentId || issue.assigneeAgentId !== action.returnOwnerAgentId || run.agentId !== issue.assigneeAgentId; + retry = { actionId: action.id, runId, + unavailableReason: stale ? "Refresh the task to retry." + : unavailableReason + ?? (issue.executionRunId || issue.checkoutRunId ? "Wait for the current run to finish." : null) + ?? (agent?.status === "paused" || agent?.status === "terminated" ? "The assigned agent is unavailable." : null) + ?? (issue.executionState?.status === "pending" ? "Complete the pending review or approval before retrying." : null) + ?? (issue.blockedBy?.some(b => b.status !== "done" && b.status !== "cancelled") ? "Resolve the task’s blockers before retrying." : null), + onRetry: async () => { + try { + const receipt = await issuesApi.resolveRecoveryAction(issue.id, { actionId: action.id, outcome: "restored", sourceIssueStatus: "todo" }); + if (receipt.issue.status !== "todo" || receipt.issue.assigneeAgentId !== issue.assigneeAgentId) { + throw new Error("The task changed. Refresh and try again."); + } + } finally { onRepaired(); } + }, + }; + } let value: RecoveryValue | null = null; if (action && runId && run?.companyId === issue.companyId && record(run.contextSnapshot).issueId === issue.id && failure.reason === "workspace_base_ref_unresolved" && typeof failure.requestedRef === "string") { @@ -51,7 +90,7 @@ export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableR const stale = action.status !== "active" || issue.status !== "blocked" || issue.assigneeAgentId !== action.returnOwnerAgentId || run.agentId !== issue.assigneeAgentId || (typeof currentRef === "string" && Boolean(currentRef.trim()) && currentRef !== requestedRef); const reason = stale ? "This repair no longer matches the task. Refresh to review its current state." - : unavailableReason + : baseRefUnavailableReason ?? unavailableReason ?? (issue.executionRunId || issue.checkoutRunId ? "Wait for the current run to finish." : null) ?? (agent?.status === "paused" || agent?.status === "terminated" ? "The assigned agent is unavailable." : null) ?? (issue.executionState?.status === "pending" ? "Complete the pending review or approval before retrying." : null) @@ -73,5 +112,5 @@ export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableR }, } }; } - return {children}; + return {children}; } diff --git a/ui/src/components/WorkspaceRetryButton.tsx b/ui/src/components/WorkspaceRetryButton.tsx new file mode 100644 index 00000000000..84577530a8a --- /dev/null +++ b/ui/src/components/WorkspaceRetryButton.tsx @@ -0,0 +1,36 @@ +import { useRef, useState } from "react"; +import { Loader2, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useCurrentWorkspaceRetry } from "./WorkspaceBaseRefRecovery"; + +export function WorkspaceRetryControl() { + const retry = useCurrentWorkspaceRetry(); + return retry ? : null; +} + +export function WorkspaceRetryButton({ onRetry, unavailableReason }: { + onRetry: () => Promise; + unavailableReason: string | null; +}) { + const inFlight = useRef(false); + const [pending, setPending] = useState(false); + const [requested, setRequested] = useState(false); + const [error, setError] = useState(null); + async function retry() { + if (inFlight.current || requested || unavailableReason) return; + inFlight.current = true; + setPending(true); + setError(null); + try { await onRetry(); setRequested(true); } + catch (cause) { setError(cause instanceof Error ? cause.message : "Couldn’t retry. Try again."); } + finally { inFlight.current = false; setPending(false); } + } + return
+ + {error ?

{error}

: null} +
; +} diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx index 151ee60400e..0d7eca83f18 100644 --- a/ui/src/components/task-chat/TaskChatSystemNotice.tsx +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -1,4 +1,4 @@ -import { useWorkspaceBaseRefRecovery } from "@/components/WorkspaceBaseRefRecovery"; +import { useWorkspaceBaseRefRecovery, useWorkspaceRetry } from "@/components/WorkspaceBaseRefRecovery"; import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRecoveryNotice"; import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "@/components/DispositionRecoveryNotice"; import { useId, useState } from "react"; @@ -59,6 +59,7 @@ export function TaskChatSystemNotice({ }) { const recoverySnapshot = useDispositionRecoverySnapshot(item.metadata); const branchRecovery = useWorkspaceBaseRefRecovery(item.metadata); + const workspaceRetry = useWorkspaceRetry(item.metadata); const streamlined = useStreamlinedTaskChatPresentation(); const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen)); const detailsId = useId(); @@ -87,6 +88,7 @@ export function TaskChatSystemNotice({ .catch(() => undefined); }; + if (item.author === "system" && workspaceRetry) return null; if (item.author === "system" && branchRecovery) return ; if (item.author === "system" && recoverySnapshot) { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 12f6e34acb1..311a4d2edd0 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,5 +1,6 @@ import { grokBotApi } from "@/api/grokBot"; import { WorkspaceBaseRefRecoveryProvider } from "../components/WorkspaceBaseRefRecovery"; +import { WorkspaceRetryControl } from "../components/WorkspaceRetryButton"; import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; import { canManageIssuePrivacy } from "../lib/issuePrivacy"; import { TextAttachmentContext } from "../context/TextAttachmentContext"; @@ -2538,7 +2539,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ if (activeRunQueryEnabled) void refetchActiveRun(); }} composerRef={composerRef} - composerAccessory={composerAccessory} + composerAccessory={<>{composerAccessory}} threadHeader={ !classicTaskInterfaceEnabled && (threadHeader || loadOlderButton) ? ( @@ -8096,7 +8097,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS > {resolvedDetailTab === "chat" ? ( { invalidateIssueDetail(); invalidateIssueCollections(); }} - unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace." + baseRefUnavailableReason={!canManageBoardRuntime ? "You don’t have permission to repair this task’s workspace." : null} + unavailableReason={!canResolveBoardRecoveryAction ? "You don’t have permission to retry this task." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : activePauseHold ? "Resume the task before retrying." : issue.project?.pausedAt ? "Resume the project before retrying."