mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(issues): a Retry held by a blocked subtask names it instead of failing silently (#20109)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board's Retry is the one action a person takes when a task stops; it must either start work or say why it can't yet > - Restart chaos t05 left a parent task blocked on a blocked subtask. The board clicked Retry twice; each was accepted and nothing happened > - The Retry was saved and the queue deferred it behind the unresolved blocker, which is correct. But the task still looked failed with Retry offered, so the wait was invisible > - This pull request presents a saved Retry that waits only on unfinished blockers as waiting, names the blocked subtask and links it > - The benefit is that the board sees what to act on (the subtask's own Retry) instead of clicking a Retry that cannot run ## Linked Issues or Issue Description Refs #19831 (durable ownership plan). Restart chaos wave `w-20261108T200620-s9701`, task t05 (claude, M1, K4). **Problem:** parent CHA-5 was blocked by subtask CHA-10, which was itself blocked on a board recovery action. The board pressed Retry on CHA-5 at 20:27:46 and 20:40:46. Each `POST /issues/:id/retry` returned 202, and the saved Retry stayed `deferred_issue_execution` behind CHA-10, so no run started. The task's interruption presentation kept saying `failed` with `canRetry: true` and gave no reason. ## What Changed - `server/src/services/task-interruption-presentation.ts`: - For tasks with a saved (deferred, user-requested) Retry, the loader reads the queue's own dependency readiness (`listDependencyReadiness`). That counts cancelled blockers and done blockers whose workspace has not finalized, like the queue does. - When the saved Retry for the latest run is held only by unfinished blockers (no execution wait, no preparation), the task is presented as `working` with `canRetry: false` and `retryWait: { reason: "dependency_blocked", message }`. - The message has three forms: - a blocked blocker: "Retry or unblock it"; - a cancelled blocker: "Remove or replace that dependency to continue", because the queue keeps it unresolved; - otherwise: waiting for the blockers to finish. - The presentation carries no blocker names or ids. Issue reads return it to restricted agents too. - The Retry response (`POST /issues/:id/retry`) already returns this presentation, so the API says the Retry is waiting on dependencies as soon as it is saved. The blockers themselves are in the issue's `blockedBy`, which issue reads already filter per viewer. - `ui/src/components/ExecutionBlockerNotice.tsx` takes `blockers` (the issue's `blockedBy`, from `IssueDetail`). For a `dependency_blocked` wait it lists each readable unfinished blocker with a link and what it needs: "Blocked: open it to Retry" or "Cancelled: remove or replace it". Tokens only; `pnpm check:token-gates` is clean. - No shared type change, and no code comments added. ## Verification Red/green: - `server/src/__tests__/task-interruption-query.test.ts` "explains a saved Retry held by unfinished blockers instead of offering Retry again (restart chaos t05)". It sets up a parent with a failed run, a saved Retry deferred, and a blocked subtask that blocks it. - On master: `{ state: "failed", canRetry: true }`, no reason. - With this PR: `working`, `canRetry: false`, `dependency_blocked`. The presentation has no blocker title or identifier. - A cancelled blocker gets the remove-or-replace message. Once the blocker is done, the dependency wait is gone. - `ui/src/components/ExecutionBlockerNotice.test.tsx`: the notice links the blocked and the cancelled blockers with their actions, skips the done one, and offers no second Retry. Regression: `task-interruption-query` (25 tests) and `ExecutionBlockerNotice` (16 tests) pass, as do the presentation unit tests, workspace-recovery admission, gates and copyback tests, and `heartbeat-dependency-scheduling`. `tsc --noEmit` is clean for `server` and `ui`. ## Risks - One more batched read (dependency readiness plus blocker statuses), only for tasks in the page that have a saved Retry. - Presentation only. Admission is unchanged: the queue still runs the saved Retry when the blockers are done. - A Retry saved before a blocker was added shows the same wait, which is accurate, because the queue holds it too. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use (shell, file editing). ## 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 (#20069) - [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 (code comments; no user docs affected) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77168d3367
commit
4420a5adcf
6 files changed
+150
-6
No files matched your search
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentWakeupRequests, chatActions, chatEndpoints, companies, createDb, applyPendingMigrations, heartbeatRuns, issueRecoveryActions, issues,
|
||||
import { agents, agentWakeupRequests, chatActions, chatEndpoints, companies, createDb, applyPendingMigrations, heartbeatRuns, issueRecoveryActions,
|
||||
issueRelations, issues,
|
||||
toolApplications, toolConnections,
|
||||
nativeRunFinalizations, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { createSystemRecovery } from "../services/system-recovery-state.js";
|
||||
@@ -96,6 +97,33 @@ describe.skipIf(!support.supported)("task interruption latest run query", () =>
|
||||
.toMatchObject({ state: "failed", canRetry: true });
|
||||
});
|
||||
|
||||
it("explains a saved Retry held by unfinished blockers instead of offering Retry again (restart chaos t05)", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Waiting parent", issuePrefix: `W${companyId.slice(0, 7)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Parent agent", adapterType: "paperclip_runner" });
|
||||
const [parent] = await db.insert(issues).values({ companyId, title: "Write the brief", identifier: "CHA-5",
|
||||
status: "blocked", assigneeAgentId: agentId }).returning();
|
||||
const [child] = await db.insert(issues).values({ companyId, title: "Part 2", identifier: "CHA-10", parentId: parent.id,
|
||||
status: "blocked", assigneeAgentId: agentId }).returning();
|
||||
await db.insert(issueRelations).values({ companyId, issueId: child.id, relatedIssueId: parent.id, type: "blocks" });
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: parent.id, scopeKind: "issue",
|
||||
status: "failed", runtimeMode: "native", errorCode: "native_session_interrupted", finishedAt: new Date(Date.now() - 60_000) });
|
||||
const [retry] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "on_demand", reason: "task_retry",
|
||||
status: "deferred_issue_execution", requestedByActorType: "user", requestedAt: new Date(Date.now() - 30_000),
|
||||
payload: { issueId: parent.id, taskRetry: { sourceRunId: runId } } }).returning();
|
||||
const presentation = await taskInterruptionPresentation(db, parent);
|
||||
expect(presentation).toMatchObject({ state: "working", canRetry: false, intentId: retry.id,
|
||||
retryWait: { reason: "dependency_blocked" } });
|
||||
expect(presentation?.retryWait?.message).toContain("blocked task this one depends on");
|
||||
expect(JSON.stringify(presentation)).not.toContain("Part 2");
|
||||
expect(JSON.stringify(presentation)).not.toContain("CHA-10");
|
||||
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, child.id));
|
||||
expect((await taskInterruptionPresentations(db, companyId, [parent])).get(parent.id)?.retryWait?.message)
|
||||
.toContain("Remove or replace that dependency");
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, child.id));
|
||||
expect((await taskInterruptionPresentation(db, parent))?.retryWait?.reason).not.toBe("dependency_blocked");
|
||||
});
|
||||
|
||||
it("shows a queued-message Interrupt as handing off, not as a failed attempt", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(), userId = "board-user";
|
||||
await db.insert(companies).values({ id: companyId, name: "Interrupted task", issuePrefix: `Q${companyId.slice(0, 7)}` });
|
||||
|
||||
@@ -343,3 +343,22 @@ describe("scheduled resource wait presentation", () => {
|
||||
.toMatchObject({ state: "failed", canRetry: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("saved Retry held by dependencies", () => {
|
||||
const failedRun = { ...run, status: "failed", errorCode: "native_session_interrupted", resultJson: {} };
|
||||
const retryWait = { id: "retry", requestedAt: new Date(0), sourceRunId: "run" };
|
||||
const project = (retryDependencies: { count: number; blocked: boolean; cancelled: boolean; savingFilesOnly: boolean }) =>
|
||||
projectTaskInterruption(issue, { run: failedRun, agent, retryWait, paused: false, approval: false, retryDependencies });
|
||||
|
||||
it.each([
|
||||
[{ count: 1, blocked: true, cancelled: false, savingFilesOnly: false }, "Retry or unblock it"],
|
||||
[{ count: 1, blocked: false, cancelled: true, savingFilesOnly: false }, "Remove or replace that dependency"],
|
||||
[{ count: 1, blocked: false, cancelled: false, savingFilesOnly: true }, "to save its files"],
|
||||
[{ count: 2, blocked: false, cancelled: false, savingFilesOnly: false }, "to finish"],
|
||||
])("names what the saved Retry waits on (%o)", (dependencies, text) => {
|
||||
const presentation = project(dependencies);
|
||||
expect(presentation).toMatchObject({ state: "working", canRetry: false, intentId: "retry",
|
||||
retryWait: { reason: "dependency_blocked" } });
|
||||
expect(presentation?.retryWait?.message).toContain(text);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ type ProjectionState = {
|
||||
ownerStopRequired?: boolean;
|
||||
/** The latest wake for a task with no run yet, when admission refused it. */
|
||||
admissionWait?: { id: string; status: string; reason: string | null; agentId: string };
|
||||
retryDependencies?: RetryDependencies;
|
||||
};
|
||||
|
||||
// A task read reports the change that made a task fail, not every later read of
|
||||
@@ -122,6 +123,15 @@ const retryWaitMessages: Record<string, string> = {
|
||||
retry_source_changed: "Retry cannot continue because its saved message changed. Send a new message to continue from saved work.",
|
||||
};
|
||||
|
||||
type RetryDependencies = { count: number; blocked: boolean; cancelled: boolean; savingFilesOnly: boolean };
|
||||
|
||||
function dependencyRetryWaitMessage(dependencies: RetryDependencies): string {
|
||||
if (dependencies.cancelled) return "Retry is waiting on a cancelled task this one depends on. Remove or replace that dependency to continue. Your Retry is saved.";
|
||||
if (dependencies.blocked) return "Retry is waiting on a blocked task this one depends on. Retry or unblock it; this task continues when it is done. Your Retry is saved.";
|
||||
if (dependencies.savingFilesOnly) return "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
|
||||
return "Retry is waiting for the tasks this one depends on to finish. Your Retry is saved.";
|
||||
}
|
||||
|
||||
// A task that never started can wait for a temporary admission hold. The
|
||||
// assigned-task liveness sweep admits it once the hold clears; no Retry needed.
|
||||
const admissionWaitMessages: Record<string, string> = {
|
||||
@@ -238,6 +248,10 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
|
||||
if (nativeResumeAt && !system && now - nativeResumeAt < NATIVE_RESUME_DISPATCH_GRACE_MS)
|
||||
return { ...base, state: "working" };
|
||||
if (recoveryRunning) return { ...base, state: "working", ...(recoveryStep ? { recoveryStep } : {}) };
|
||||
const dependencies = input.retryDependencies;
|
||||
if (retryWait?.sourceRunId === run.id && !retryWait.executionWaitReason && !retryWait.preparationStatus && dependencies?.count)
|
||||
return { ...base, intentId: retryWait.id, state: "working",
|
||||
retryWait: { reason: "dependency_blocked", message: dependencyRetryWaitMessage(dependencies) } };
|
||||
if (retryWait?.sourceRunId === run.id && retryWait.executionWaitReason) {
|
||||
const reason = Object.hasOwn(retryWaitMessages, retryWait.executionWaitReason) ? retryWait.executionWaitReason : "execution_recovery";
|
||||
// Keep the latest input receipt so message submission can settle independently.
|
||||
@@ -431,6 +445,25 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
|
||||
const runMap = byIssue(runs), waitMap = new Map<string, typeof waits[number]>();
|
||||
const retryWaitMap = byIssue(waits.filter(wait => wait.sourceRunId));
|
||||
const retryIssueIds = [...retryWaitMap.keys()];
|
||||
const retryDependencyMap = new Map<string, RetryDependencies>();
|
||||
if (retryIssueIds.length) {
|
||||
const { issueService } = await import("./issues.js");
|
||||
const readiness = await issueService(db).listDependencyReadiness(companyId, retryIssueIds);
|
||||
const blockerIds = [...new Set([...readiness.values()].flatMap(row => row.unresolvedBlockerIssueIds))];
|
||||
const statusRows = blockerIds.length ? await db.select({ id: issues.id, status: issues.status }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, blockerIds))) : [];
|
||||
const statusById = new Map(statusRows.map(row => [row.id, row.status]));
|
||||
for (const [waitingIssueId, row] of readiness) {
|
||||
const ids = [...new Set(row.unresolvedBlockerIssueIds)];
|
||||
if (!ids.length) continue;
|
||||
const statuses = ids.map(id => statusById.get(id));
|
||||
const saving = new Set(row.pendingFinalizeBlockerIssueIds);
|
||||
retryDependencyMap.set(waitingIssueId, { count: ids.length,
|
||||
blocked: statuses.includes("blocked"), cancelled: statuses.includes("cancelled"),
|
||||
savingFilesOnly: ids.every(id => saving.has(id)) });
|
||||
}
|
||||
}
|
||||
for (const wait of waits) {
|
||||
const current = waitMap.get(wait.issueId);
|
||||
if (!current || wait.requestedAt > current.requestedAt ||
|
||||
@@ -484,6 +517,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
|
||||
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
|
||||
ownerStopRequired: ownerStopRequired.has(`${issue.id}:${facts.run?.id}`),
|
||||
admissionWait: admissionWaitMap.get(issue.id),
|
||||
retryDependencies: retryDependencyMap.get(issue.id),
|
||||
}, now);
|
||||
result.set(issue.id, presentation);
|
||||
const run = facts.run;
|
||||
|
||||
@@ -3,8 +3,13 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
const failed: TaskInterruptionPresentation = {
|
||||
intentId: "intent", responsibility: "system", state: "failed", nextRetryAt: null,
|
||||
canRetry: true, userAction: null, sourceRunIds: ["source"], observedRunId: "source",
|
||||
@@ -47,6 +52,36 @@ describe("ordinary task interruption", () => {
|
||||
expect(container.textContent).toBe("Waiting for the budget to allow new work. The task starts automatically when it does.");
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
it("names and links the readable blockers a saved Retry waits on, with no second Retry (restart chaos t05)", async () => {
|
||||
const message = "Retry is waiting on a blocked task this one depends on. Retry or unblock it; this task continues when it is done. Your Retry is saved.";
|
||||
const summary = (id: string, identifier: string, title: string, status: string) =>
|
||||
({ id, identifier, title, status, priority: "medium", assigneeAgentId: null, assigneeUserId: null }) as never;
|
||||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||||
blockers={[summary("child", "CHA-10", "Part 2", "blocked"), summary("gone", "CHA-11", "Part 3", "cancelled"),
|
||||
summary("done", "CHA-9", "Part 1", "done")]} />));
|
||||
expect(container.textContent).toContain(message);
|
||||
const links = [...container.querySelectorAll<HTMLAnchorElement>("[data-testid='retry-waiting-on'] a")].map(link => link.getAttribute("href"));
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toContain("CHA-10");
|
||||
expect(container.textContent).toContain("Blocked: open it to Retry");
|
||||
expect(container.textContent).toContain("Cancelled: remove or replace it");
|
||||
expect(container.textContent).not.toContain("Part 1");
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
});
|
||||
it("shows a locked blocker without reading its status, and a finished blocker still saving files", async () => {
|
||||
const message = "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
|
||||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||||
blockers={[{ id: "secret", identifier: "CHA-12", locked: true } as never]} />));
|
||||
expect(container.querySelector("[data-testid='locked-issue-chip']")?.textContent).toContain("CHA-12");
|
||||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||||
blockers={[{ id: "done", identifier: "CHA-9", title: "Part 1", status: "done", priority: "medium",
|
||||
assigneeAgentId: null, assigneeUserId: null } as never]} />));
|
||||
expect(container.querySelector("[data-testid='retry-waiting-on'] a")?.getAttribute("href")).toContain("CHA-9");
|
||||
expect(container.textContent).toContain("Saving its files");
|
||||
});
|
||||
it("names the saved-work recovery step instead of a bare queue", async () => {
|
||||
await render({ ...failed, state: "working", canRetry: false, recoveryStep: "saving_previous_files" });
|
||||
expect(container.textContent).toBe("Saving files from the previous attempt…");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describeError } from "../api/errors";
|
||||
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
|
||||
import type { IssueRelationIssueSummary, TaskInterruptionPresentation } from "@paperclipai/shared";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSecondTick } from "../hooks/useSecondTick";
|
||||
import { TaskProjectAccessNotice } from "./TaskProjectAccessNotice";
|
||||
import { ProviderFailureNotice } from "./ProviderFailureNotice";
|
||||
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
|
||||
|
||||
const recoveryStepText: Record<NonNullable<TaskInterruptionPresentation["recoveryStep"]>, string> = {
|
||||
checking_previous_run: "Checking that the previous attempt stopped",
|
||||
@@ -13,9 +16,16 @@ const recoveryStepText: Record<NonNullable<TaskInterruptionPresentation["recover
|
||||
finishing_recovery: "Finishing recovery of saved work",
|
||||
};
|
||||
|
||||
const blockerAction: Record<string, string> = {
|
||||
blocked: "Blocked: open it to Retry",
|
||||
cancelled: "Cancelled: remove or replace it",
|
||||
done: "Saving its files",
|
||||
};
|
||||
|
||||
/** One ordinary interruption surface. Technical records remain in diagnostics. */
|
||||
export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName }: {
|
||||
export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimistic, takingLonger, error, className, issueId, agentName, projectName, blockers }: {
|
||||
presentation: TaskInterruptionPresentation | null | undefined;
|
||||
blockers?: IssueRelationIssueSummary[];
|
||||
canRetry: boolean;
|
||||
onRetry: () => void;
|
||||
optimistic?: boolean;
|
||||
@@ -47,8 +57,26 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
|
||||
if (step && presentation?.state === "working") return <div role="status" aria-label="Task status"
|
||||
className={cn("text-sm text-muted-foreground", className)}>{`${step}…`}</div>;
|
||||
// A task that has not started yet names the admission hold it waits for.
|
||||
if (!optimistic && presentation?.state === "working" && presentation.retryWait) return <div role="status"
|
||||
aria-label="Task status" className={cn("text-sm text-muted-foreground", className)}>{presentation.retryWait.message}</div>;
|
||||
if (!optimistic && presentation?.state === "working" && presentation.retryWait) {
|
||||
const dependencies = presentation.retryWait.reason === "dependency_blocked" ? blockers ?? [] : [];
|
||||
const unfinished = dependencies.filter(blocker => isLockedIssueStub(blocker) || blocker.status !== "done");
|
||||
const waitingOn = unfinished.length ? unfinished : dependencies;
|
||||
return <div role="status" aria-label="Task status" className={cn("text-sm text-muted-foreground", className)}>
|
||||
<p>{presentation.retryWait.message}</p>
|
||||
{waitingOn.length ? <ul className="mt-1 space-y-0.5" data-testid="retry-waiting-on">
|
||||
{waitingOn.map(blocker => isLockedIssueStub(blocker)
|
||||
? <li key={blocker.id} className="flex min-w-0 items-center gap-2"><LockedIssueChip identifier={blocker.identifier} /></li>
|
||||
: <li key={blocker.id} className="flex min-w-0 items-center gap-2">
|
||||
<Link to={createIssueDetailPath(blocker.identifier ?? blocker.id)}
|
||||
className="font-mono text-xs text-foreground underline-offset-2 hover:underline">
|
||||
{blocker.identifier ?? "Task"}
|
||||
</Link>
|
||||
<span className="min-w-0 truncate">{blocker.title}</span>
|
||||
<span className="shrink-0 text-xs">{blockerAction[blocker.status] ?? blocker.status.replace(/_/g, " ")}</span>
|
||||
</li>)}
|
||||
</ul> : null}
|
||||
</div>;
|
||||
}
|
||||
if (optimistic || !presentation || presentation.responsibility !== "system" ||
|
||||
!["failed", "retry_scheduled"].includes(presentation.state)) return null;
|
||||
return (
|
||||
|
||||
@@ -8188,7 +8188,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
action={issue.activeRecoveryAction ?? null} />
|
||||
)}
|
||||
{resolvedDetailTab === "chat" && (
|
||||
<ExecutionBlockerNotice className={shellSectionClass} presentation={issue.interruption}
|
||||
<ExecutionBlockerNotice className={shellSectionClass} presentation={issue.interruption} blockers={issue.blockedBy}
|
||||
issueId={issue.id} agentName={agentMap.get(issue.assigneeAgentId ?? "")?.name} projectName={issue.project?.name}
|
||||
canRetry={canResolveBoardRecoveryAction && !(issue.executionBlocker?.workspaceRepairRequired
|
||||
&& isRetainedWorkspaceExportCause(issue.executionBlocker.cause) && retainedRecoveryOwnsRetry)} onRetry={taskRetry.retry}
|
||||
|
||||
Reference in new issue
Block a user