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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 21:49:44 -05:00
1 parent d9bcf4dfe8
commit 16689de291
20 files changed
+353 -39

No files matched your search

+11
View File
@@ -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
+11 -3
View File
@@ -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
+11 -3
View File
@@ -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
+1 -1
View File
@@ -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;
@@ -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,
@@ -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");
});
+15 -2
View File
@@ -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<string, unknown> | 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" ||
+30 -5
View File
@@ -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<typeof resolveSourceWorkspaceConfiguration>[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;
});
@@ -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<string, unknown> | 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")));
@@ -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;
+1 -1
View File
@@ -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(
@@ -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", () => {
@@ -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",
};
}
@@ -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" } };
}
+5 -2
View File
@@ -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 <div id={anchorId}><WorkspaceBaseRefRecoveryNotice key={`${branchRecovery.actionId}:${branchRecovery.runId}`} {...branchRecovery.props} /></div>;
if (authorType === "system" && recoverySnapshot) {
@@ -5847,6 +5849,7 @@ export function IssueChatThread({
externalReferences,
linkCaseReferences = false,
}: IssueChatThreadProps) {
const workspaceRetry = useCurrentWorkspaceRetry();
const location = useLocation();
const lastScrolledHashRef = useRef<string | null>(null);
const didInitialHashScrollDecisionRef = useRef(false);
@@ -6634,7 +6637,7 @@ export function IssueChatThread({
onResume={onResumeFromBacklog}
resuming={resumeFromBacklogPending}
/>
{recoveryAction ? (
{recoveryAction && !workspaceRetry ? (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
@@ -4,7 +4,8 @@ import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Issue, IssueCommentMetadata } from "@paperclipai/shared";
import { WorkspaceBaseRefRecoveryProvider, useWorkspaceBaseRefRecovery } from "./WorkspaceBaseRefRecovery";
import { WorkspaceBaseRefRecoveryProvider, useWorkspaceBaseRefRecovery, useCurrentWorkspaceRetry } from "./WorkspaceBaseRefRecovery";
import { WorkspaceRetryControl } from "./WorkspaceRetryButton";
import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice";
const mocks = vi.hoisted(() => ({ 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 <WorkspaceRetryControl />;
return value ? <WorkspaceBaseRefRecoveryNotice {...value.props} /> : <p>No repair</p>;
}
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");
});
+44 -5
View File
@@ -11,6 +11,18 @@ function record(value: unknown): Record<string, unknown> {
type RecoveryValue = { actionId: string; runId: string; props: WorkspaceBaseRefRecoveryNoticeProps };
const Context = createContext<RecoveryValue | null>(null);
type RetryValue = { actionId: string; runId: string; unavailableReason: string | null; onRetry: () => Promise<void> };
const RetryContext = createContext<RetryValue | null>(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<string, Pick<Agent, "name" | "status">>;
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 <Context.Provider value={value}>{children}</Context.Provider>;
return <RetryContext.Provider value={retry}><Context.Provider value={value}>{children}</Context.Provider></RetryContext.Provider>;
}
@@ -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 ? <WorkspaceRetryButton key={`${retry.actionId}:${retry.runId}`} {...retry} /> : null;
}
export function WorkspaceRetryButton({ onRetry, unavailableReason }: {
onRetry: () => Promise<void>;
unavailableReason: string | null;
}) {
const inFlight = useRef(false);
const [pending, setPending] = useState(false);
const [requested, setRequested] = useState(false);
const [error, setError] = useState<string | null>(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 <div className="flex flex-col items-start gap-2 py-3">
<Button size="xs" variant="outline" disabled={pending || requested || Boolean(unavailableReason)}
title={unavailableReason ?? undefined} onClick={() => void retry()}>
{pending ? <Loader2 aria-hidden className="size-3 animate-spin motion-reduce:animate-none" /> : <RotateCcw aria-hidden className="size-3" />}
{pending || requested ? "Retrying…" : "Retry"}
</Button>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>;
}
@@ -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 <WorkspaceBaseRefRecoveryNotice key={`${branchRecovery.actionId}:${branchRecovery.runId}`} {...branchRecovery.props} />;
if (item.author === "system" && recoverySnapshot) {
+4 -2
View File
@@ -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={<><WorkspaceRetryControl />{composerAccessory}</>}
threadHeader={
!classicTaskInterfaceEnabled &&
(threadHeader || loadOlderButton) ? (
@@ -8096,7 +8097,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
>
{resolvedDetailTab === "chat" ? (
<WorkspaceBaseRefRecoveryProvider issue={issue} agentMap={agentMap} onRepaired={() => { 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."