From 16689de291d42bf6aaaaaa59f332285879e60173 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:49:44 -0500 Subject: [PATCH] fix: add one-click retry for restricted workspace setup (#16909) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks need a clear way to resume after a setup failure. > - A shared repository can contain another task that the assigned agent cannot read. > - Paperclip correctly stops before the agent starts, but the user must then understand workspace settings to recover. > - This pull request gives that failure a single Retry button and selects a clean copy of the configured repository through normal admission. > - The task keeps its assigned agent and execution settings, while access checks and existing work remain protected. ## Linked Issues or Issue Description Related: #16895 classifies the restricted source failure. This change adds the user recovery action. #16906 handles retained files after execution; this change handles a setup failure before execution starts. **What existing behavior does this improve?** Recovery of a task whose shared repository includes a task workspace the assigned agent cannot read. **Current behavior** The task stops before provider work and asks the user to understand workspace choices or access grants. **Proposed behavior** Show Retry above the task message box, independent of loaded conversation history. One click requests one attempt using a clean checkout of the configured remote. Show Retrying while the request is pending. Show an error only if the request fails. **Reason and benefit** The user can resume work without understanding repository isolation or changing access to another task. **Breaking changes** None. The recovery source value is server-owned. The public workspace selection request does not accept it. No migration is required. ## What Changed - Add one Retry control above the composer in both task conversation views. It stays available when an older notice is outside the loaded comment pages. - Bind recovery to the latest failed run, current assignee, and recorded proof that provider work did not start. - Use the existing atomic recovery route and pending workspace selection. Normal admission clears the old session and creates the isolated checkout. - Choose a fresh task-owned branch for recovery, including fixed project branches, existing task branches, and adapter branch overrides. Preserve the model, environment, setup commands, pending user workspace choices, previous files, and all execution gates. - Update execution documentation and both workspace maps. ## Verification - Passed all 461 tests in the eight affected database, Git, HTTP, workspace binding, and conversation suites after the review fixes. An additional 292 tests passed after the final compatibility fixes, including IssueDetail and recovery action routing. - Concurrent HTTP retries produce one wakeup. Paused projects and runs that started provider work reject recovery without changing the workspace. - Real Git tests exclude private files, private commits, and unpublished original files from the clean checkout, preserve the original, and exercise reuse, cleanup, and reopen. - Verified in a running local app: a task failed twice, displayed only Retry, and reached Done after one click. A second browser test put the failure notice behind 501 newer comments and used a fixed project branch. Retry stayed visible and the task reached Done. The credential-free process worker used the selected checkout and wrote its result there. This does not claim a live model-provider run. - The first manual fixture lacked a responsible-user membership and failed after successful workspace recovery. Corrected the fixture, retained that failure evidence, and verified the full flow with ordinary authorization. - Full typecheck, build, token gates, and map checks passed. The full local suite was stopped after review changed its source during execution; current-head CI provides the complete suite result. - An earlier unrelated peer-comment read test hit a socket hangup. The next complete affected-suite run passed; the first failure is retained in local evidence. - Current-head CI hit an unchanged runner credential-concurrency test failure. That lane passed on the preceding commit, and all 42 credential tests passed locally. The original failed job is retained; the one-time rerun passed. All latest-head CI checks are green (55 success, 2 skipped, 1 neutral), and Greptile is 5/5 with both review threads resolved. ## Risks - A remote repository outage can still prevent the next attempt. Retry does not grant access or copy unpublished files from the old checkout. - Recovery accepts only the latest recorded pre-provider failure and preserves normal execution gates. This limits duplicate work and stale recovery requests. - No database migration, dependency, or lockfile change. - The first review found fixed-branch retry loops and dependence on paginated notice history. Both had failing regressions before the fixes and now pass targeted tests and the combined browser journey. - Full CI then caught assumptions about missing run evidence and unrelated recovery actions. The final changes tolerate absent evidence and scope the new lookup to board-owned actions with valid run IDs. The previously failing suites now pass locally. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution, and browser verification. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 11 +++ maps/agent-access-map/workspaces.md | 14 +++- maps/feature-map/workspaces.md | 14 +++- packages/shared/src/types/task-workspace.ts | 2 +- .../shared/src/validators/task-workspace.ts | 2 +- ...tbeat-workspace-branch-containment.test.ts | 4 +- server/src/routes/issues.ts | 17 +++- server/src/services/execution-workspaces.ts | 35 ++++++-- .../heartbeat/workspace-preparation.test.ts | 83 ++++++++++++++++++- .../isolated-remote-workspace-source.ts | 2 +- server/src/services/recovery/service.ts | 2 +- .../services/recovery/stranded-notice.test.ts | 9 +- .../src/services/recovery/stranded-notice.ts | 6 +- .../services/restricted-workspace-retry.ts | 46 ++++++++++ ui/src/components/IssueChatThread.tsx | 7 +- .../WorkspaceBaseRefRecovery.test.tsx | 43 +++++++++- .../components/WorkspaceBaseRefRecovery.tsx | 49 +++++++++-- ui/src/components/WorkspaceRetryButton.tsx | 36 ++++++++ .../task-chat/TaskChatSystemNotice.tsx | 4 +- ui/src/pages/IssueDetail.tsx | 6 +- 20 files changed, 353 insertions(+), 39 deletions(-) create mode 100644 server/src/services/restricted-workspace-retry.ts create mode 100644 ui/src/components/WorkspaceRetryButton.tsx 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."