fix(workspaces): name a failed workspace setup command on the task page

A project provision command that exits non-zero fails the run as
setup_failed. The task page showed only "Failed" and a generic recovery
notice; the real error (command, exit status, output) was visible only on
the run page.

Record a bounded, redacted setupCommandFailure (step, configured command,
exit code, output tail) on the run result and project it as
TaskInterruptionPresentation.setupFailure for setup_failed runs, with a
fallback for runs recorded before the structured field. The task notice
names the command and says what to change. Classification, retry and
recovery behavior are unchanged.

The restart-chaos config wave reads the new field for its actionable
message check.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DottaandClaude committed 2026-11-10 00:35:23 -06:00
1 parent bf1524c5f8
commit a9cb2b44f1
12 files changed
+242 -11

No files matched your search

@@ -124,6 +124,18 @@ export interface ExecutionReconciliation {
export interface TaskInterruptionPresentation {
providerFailure?: import("../provider-failure.generated.js").ProviderFailure;
workspaceFailure?: { message: string };
/**
* A configured workspace setup command exited non-zero before the agent
* started (`setup_failed`). Bounded and redacted: the step, the configured
* command, its exit status and a short tail of its output.
*/
setupFailure?: {
step: "workspace_provision" | "workspace_seed" | "workspace_runtime_provision";
command: string;
exitCode: number | null;
output: string | null;
message: string;
};
/** A persisted Retry admission wait, normalized to public text by the server. */
retryWait?: { reason: string; message: string };
intentId: string | null;
@@ -1,4 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { setupCommandFailureResultJson } from "../services/workspace-setup-command-failure.js";
import { createHash, randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
@@ -985,6 +986,37 @@ describe("realizeExecutionWorkspace", () => {
}
});
it("names a failed project provision command with its exit status and output (CF1)", async () => {
const repoRoot = await createTempRepo();
const config = buildExecutionWorkspaceAdapterConfig({
agentConfig: {},
projectPolicy: parseProjectExecutionWorkspacePolicy({
enabled: true,
defaultMode: "isolated_workspace",
workspaceStrategy: { type: "git_worktree", baseRef: "main", provisionCommand: "bash ./scripts/missing-provisioner.sh" },
}),
issueSettings: null,
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
try {
const failure = await realizeExecutionWorkspace({
base: { baseCwd: repoRoot, source: "project_primary", projectId: "project-1", workspaceId: "workspace-1", repoUrl: null, repoRef: "HEAD" },
config,
issue: { id: "issue-1", identifier: "TEST-1", title: "Broken setup" },
agent: { id: "agent-1", name: "Test agent", companyId: "company-1" },
}).then(() => null, (error: unknown) => error);
expect(failure).toBeInstanceOf(Error);
expect(setupCommandFailureResultJson(failure)).toEqual({ setupCommandFailure: {
step: "workspace_provision", command: "bash ./scripts/missing-provisioner.sh", exitCode: 127,
output: expect.stringContaining("No such file or directory"),
message: expect.stringMatching(/^The project's workspace provision command "bash \.\/scripts\/missing-provisioner\.sh" failed with exit code 127: .*No such file or directory/),
} });
} finally {
await fs.rm(repoRoot, { recursive: true, force: true });
}
});
it.each([false, true])("realizes a plain Paperclip checkout without a local seed instance (image default env: %s)", async (defaultConfigInEnv) => {
const repoRoot = await createTempRepo();
const paperclipHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-env-only-home-")));
@@ -68,6 +68,7 @@ import type { getRunLogStore, RunLogHandle } from "../run-log-store.js";
import type { createHeartbeatRunState, getAdapterSessionCodec } from "./run-state.js";
import type { EffectiveRunSessionConfigMetadata } from "./workspaces.js";
import type { Db, agents } from "@paperclipai/db";
import { setupCommandFailureResultJson } from "../workspace-setup-command-failure.js";
type Run = typeof heartbeatRuns.$inferSelect;
type Agent = typeof agents.$inferSelect;
@@ -1519,6 +1520,8 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
message: redactCurrentUserText(identityRedactor.redact(providerFailure.message), currentUserRedactionOptions) } } : {}),
...(connectionFailure ? { connectionFailure } : {}),
...(workspaceProviderFailure ? { workspaceProviderFailure } : {}),
// Names the configured setup command that exited non-zero, for the task page.
...setupCommandFailureResultJson(outerErr),
// A lease lost during setup surfaces as the thrown abort reason, or as
// the signal's reason when a setup step threw a different error after it.
...controllerLeaseLossEvidence(outerErr),
@@ -195,6 +195,57 @@ it("preserves the actual workspace preparation error without changing retry auth
.not.toHaveProperty("workspaceFailure");
});
describe("setup command failure presentation (CF1)", () => {
const setupError = "Execution workspace provision command \"bash ./scripts/provision-worktree.sh\" failed: bash: ./scripts/provision-worktree.sh: No such file or directory";
const setupRun = { ...run, errorCode: "setup_failed", error: setupError, resultJson: { setupCommandFailure: {
step: "workspace_provision", command: "bash ./scripts/provision-worktree.sh", exitCode: 127,
output: "bash: ./scripts/provision-worktree.sh: No such file or directory" } } };
it("names the broken setup command on the task without changing retry authority", () => {
const withCause = projectTaskInterruption(issue, { run: setupRun, agent, paused: false, approval: false });
const withoutCause = projectTaskInterruption(issue, { run: { ...setupRun, error: "x", resultJson: null }, agent, paused: false, approval: false });
expect(withCause).toMatchObject({ state: "failed", canRetry: true, userAction: null, setupFailure: {
step: "workspace_provision", command: "bash ./scripts/provision-worktree.sh", exitCode: 127,
message: "The project's workspace provision command \"bash ./scripts/provision-worktree.sh\" failed with exit code 127: bash: ./scripts/provision-worktree.sh: No such file or directory" } });
const { setupFailure: _named, ...rest } = withCause!;
expect(rest).toEqual(withoutCause);
expect(withCause).not.toHaveProperty("workspaceFailure");
});
it("recognizes a setup failure recorded before the structured cause existed", () => {
expect(projectTaskInterruption(issue, { run: { ...setupRun, resultJson: null }, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", setupFailure: { step: "workspace_provision", command: "bash ./scripts/provision-worktree.sh",
exitCode: null, output: "bash: ./scripts/provision-worktree.sh: No such file or directory" } });
});
it("keeps a transient setup retry scheduled and only adds the name", () => {
const retrying = { ...setupRun, status: "scheduled_retry", scheduledRetryAt: new Date(Date.now() + 60_000), scheduledRetryReason: "transient_failure" };
const named = projectTaskInterruption(issue, { run: retrying, agent, paused: false, approval: false });
const unnamed = projectTaskInterruption(issue, { run: { ...retrying, error: "x", resultJson: null }, agent, paused: false, approval: false });
expect(named?.setupFailure?.command).toBe("bash ./scripts/provision-worktree.sh");
const { setupFailure: _named, ...rest } = named!;
expect(rest).toEqual(unnamed);
});
it("bounds the output tail and strips credentials from the command", () => {
const noisy = { ...setupRun, resultJson: { setupCommandFailure: { step: "workspace_provision",
command: "GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789 ./setup.sh --password hunter22", exitCode: 1,
output: `${"x".repeat(5_000)}\nfinal line` } } };
const failure = projectTaskInterruption(issue, { run: noisy, agent, paused: false, approval: false })?.setupFailure;
expect(failure?.command).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz0123456789");
expect(failure?.command).not.toContain("hunter22");
expect(failure?.output?.length).toBeLessThanOrEqual(300);
expect(failure?.output).toMatch(/final line$/);
});
it("does not name setup for other failures or settled tasks", () => {
expect(projectTaskInterruption(issue, { run: { ...setupRun, errorCode: "adapter_failed" }, agent, paused: false, approval: false }))
.not.toHaveProperty("setupFailure");
expect(projectTaskInterruption({ ...issue, status: "done" }, { run: setupRun, agent, paused: false, approval: false }))
.not.toHaveProperty("setupFailure");
});
});
function operatorStoppedRun() {
return { ...run, status: "cancelled", runtimeMode: "native" as const, companyId: issue.companyId,
nativeIssueId: issue.id, errorCode: "cancelled", resultJson: {
@@ -1,4 +1,5 @@
import { STORAGE_UNAVAILABLE_WAIT_RETRY_REASON } from "../storage/storage-unavailable.js";
import { readSetupCommandFailure } from "./workspace-setup-command-failure.js";
import { and, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm";
import { unidentifiedLocalLaunchRefusals } from "./native-local-process-stop.js";
import { agents, agentWakeupRequests, approvals, chatActions, companies, heartbeatRuns, issueApprovals, issueRecoveryActions,
@@ -197,6 +198,10 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio
if (providerFailure) base.providerFailure = providerFailure;
if (["failed", "scheduled_retry"].includes(run.status) && run.errorCode === "workspace_validation_failed"
&& typeof run.error === "string" && run.error.trim()) base.workspaceFailure = { message: run.error };
if (["failed", "scheduled_retry"].includes(run.status) && run.errorCode === "setup_failed") {
const setupFailure = readSetupCommandFailure(run.resultJson, run.error);
if (setupFailure) base.setupFailure = setupFailure;
}
const userAction = !issue.assigneeAgentId ? "assign_agent" : input.approval || run.errorCode === "native_provider_approval_required" ? "approval"
: isAiAuthenticationBlocked(run) ? "reconnect"
: run.errorCode === "native_provider_model_rejected" ? "choose_model"
@@ -579,20 +584,21 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
// workspace failure is the observed run's, while a scheduled retry's provider
// failure may be its predecessor's.
const disclosures = [...result.entries()].flatMap(([issueId, presentation]) =>
(presentation?.providerFailure || presentation?.workspaceFailure) && presentation.observedRunId
(presentation?.providerFailure || presentation?.workspaceFailure || presentation?.setupFailure) && presentation.observedRunId
? [{ id: presentation.observedRunId, providerFailureRunId: runMap.get(issueId)?.providerFailureRunId ?? presentation.observedRunId,
providerFailure: presentation.providerFailure, workspaceFailure: presentation.workspaceFailure, presentation }] : []);
providerFailure: presentation.providerFailure, workspaceFailure: presentation.workspaceFailure,
setupFailure: presentation.setupFailure, presentation }] : []);
if (disclosures.length) {
try {
const [redacted, settings] = await Promise.all([
createRunSecretRedactionRegistry(db).redactForRuns(companyId, disclosures.flatMap(({ id, providerFailureRunId, providerFailure, workspaceFailure }) => [
{ id: providerFailureRunId, providerFailure, workspaceFailure: undefined },
{ id, providerFailure: undefined, workspaceFailure },
createRunSecretRedactionRegistry(db).redactForRuns(companyId, disclosures.flatMap(({ id, providerFailureRunId, providerFailure, workspaceFailure, setupFailure }) => [
{ id: providerFailureRunId, providerFailure, workspaceFailure: undefined, setupFailure: undefined },
{ id, providerFailure: undefined, workspaceFailure, setupFailure },
])),
instanceSettingsService(db).getGeneral(),
]);
const safe = disclosures.map((_, index) => ({ providerFailure: redacted[2 * index]?.providerFailure,
workspaceFailure: redacted[2 * index + 1]?.workspaceFailure }));
workspaceFailure: redacted[2 * index + 1]?.workspaceFailure, setupFailure: redacted[2 * index + 1]?.setupFailure }));
const safeMessage = (value: unknown) => typeof value === "string"
? [...redactCurrentUserText(value, { enabled: settings.censorUsernameInLogs })].slice(0, 4000).join("") : "";
disclosures.forEach(({ presentation }, index) => {
@@ -607,11 +613,20 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i
if (message.trim()) presentation.workspaceFailure = { message };
else delete presentation.workspaceFailure;
}
if (presentation.setupFailure) {
const value = safe[index]?.setupFailure;
const command = safeMessage(value?.command), message = safeMessage(value?.message);
if (value && command.trim() && message.trim()) {
const output = value.output == null ? null : safeMessage(value.output) || null;
presentation.setupFailure = { ...value, command, output, message };
} else delete presentation.setupFailure;
}
});
} catch {
for (const { presentation } of disclosures) {
delete presentation.providerFailure;
delete presentation.workspaceFailure;
delete presentation.setupFailure;
}
logger.warn({ companyId, runCount: disclosures.length }, "Could not redact run failure display; explanations omitted");
}
+14 -2
View File
@@ -3116,14 +3116,24 @@ function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
*/
export class WorkspaceCommandExitError extends Error {
readonly exitCode: number | null;
/** The configured setup step and command, so the task page can name the setting that broke. */
readonly step: WorkspaceCommandPhase | null;
readonly command: string | null;
readonly output: string | null;
constructor(message: string, exitCode: number | null) {
constructor(message: string, exitCode: number | null, failure?: { step: WorkspaceCommandPhase; command: string; output: string }) {
super(message);
this.exitCode = exitCode;
this.step = failure?.step ?? null;
this.command = failure?.command ?? null;
this.output = failure?.output || null;
}
}
type WorkspaceCommandPhase = "workspace_provision" | "workspace_seed" | "workspace_runtime_provision" | "workspace_teardown";
async function runWorkspaceCommand(input: {
phase?: WorkspaceCommandPhase;
command: string;
resolvedCommand?: string;
cwd: string;
@@ -3148,6 +3158,7 @@ async function runWorkspaceCommand(input: {
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${proc.code ?? -1}`,
proc.code,
input.phase ? { step: input.phase, command: input.command, output: details } : undefined,
);
}
@@ -3276,7 +3287,7 @@ async function recordGitOperation(
async function recordWorkspaceCommandOperation(
recorder: WorkspaceOperationRecorder | null | undefined,
input: {
phase: "workspace_provision" | "workspace_seed" | "workspace_runtime_provision" | "workspace_teardown";
phase: WorkspaceCommandPhase;
command: string;
resolvedCommand?: string;
cwd: string;
@@ -3345,6 +3356,7 @@ async function recordWorkspaceCommandOperation(
? `${input.label} failed: ${details}`
: `${input.label} failed with exit code ${code ?? -1}`,
code,
{ step: input.phase, command: input.command, output: details },
);
}
@@ -0,0 +1,79 @@
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { redactSensitiveText } from "../redaction.js";
/**
* A configured workspace setup command (a project or service setting) exited
* non-zero before the agent started. The run keeps its `setup_failed`
* classification and retry behavior; this bounded record only lets the task
* page name the setting that broke and what it printed.
*/
type SetupCommandFailure = NonNullable<TaskInterruptionPresentation["setupFailure"]>;
type SetupStep = SetupCommandFailure["step"];
const SETUP_STEPS: ReadonlySet<string> = new Set<SetupStep>(["workspace_provision", "workspace_seed", "workspace_runtime_provision"]);
const MAX_COMMAND_CHARS = 200;
const MAX_OUTPUT_CHARS = 300;
const STEP_SETTINGS: Record<SetupStep, string> = {
workspace_provision: "The project's workspace provision command",
workspace_seed: "The workspace seed command",
workspace_runtime_provision: "The runtime provision command",
};
// Messages written by workspace-runtime.ts before setup failures carried structured fields.
const LEGACY_MESSAGE = /^(Execution workspace provision|Workspace seed|Runtime provision) command "([^"\n]{1,2000})" failed(?:: ([\s\S]+)| with exit code (-?\d+))$/;
const LEGACY_STEPS: Record<string, SetupStep> = {
"Execution workspace provision": "workspace_provision",
"Workspace seed": "workspace_seed",
"Runtime provision": "workspace_runtime_provision",
};
function bounded(value: string, max: number, keep: "head" | "tail"): string {
const clean = [...redactSensitiveText(value)
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, "").trim()];
if (clean.length <= max) return clean.join("");
return keep === "head" ? `${clean.slice(0, max - 1).join("")}…` : `…${clean.slice(clean.length - (max - 1)).join("")}`;
}
function normalize(step: SetupStep, command: string, exitCode: number | null, output: string | null): SetupCommandFailure | null {
const safeCommand = bounded(command, MAX_COMMAND_CHARS, "head");
if (!safeCommand) return null;
const safeOutput = output ? bounded(output, MAX_OUTPUT_CHARS, "tail") || null : null;
const status = exitCode === null ? "failed" : `failed with exit code ${exitCode}`;
return {
step, command: safeCommand, exitCode, output: safeOutput,
message: `${STEP_SETTINGS[step]} "${safeCommand}" ${status}${safeOutput ? `: ${safeOutput}` : ""}`,
};
}
/** Bounded, redacted result evidence for a run whose setup command exited non-zero. */
export function setupCommandFailureResultJson(error: unknown): { setupCommandFailure: SetupCommandFailure } | Record<string, never> {
let current: unknown = error;
for (let depth = 0; depth < 6 && current; depth += 1) {
// WorkspaceCommandExitError (workspace-runtime.ts), read by shape so this module stays light.
const value = current as { step?: unknown; command?: unknown; exitCode?: unknown; output?: unknown };
if (current instanceof Error && typeof value.step === "string" && SETUP_STEPS.has(value.step) && typeof value.command === "string") {
const failure = normalize(value.step as SetupStep, value.command,
typeof value.exitCode === "number" && Number.isInteger(value.exitCode) ? value.exitCode : null,
typeof value.output === "string" ? value.output : null);
return failure ? { setupCommandFailure: failure } : {};
}
current = current instanceof Error ? current.cause : undefined;
}
return {};
}
/** Read the recorded setup-command failure, or recognize one recorded before the structured field existed. */
export function readSetupCommandFailure(resultJson: Record<string, unknown> | null | undefined, error: unknown): SetupCommandFailure | null {
const stored = resultJson?.setupCommandFailure;
if (stored && typeof stored === "object" && !Array.isArray(stored)) {
const value = stored as Record<string, unknown>;
if (typeof value.step === "string" && SETUP_STEPS.has(value.step) && typeof value.command === "string") {
return normalize(value.step as SetupStep, value.command,
typeof value.exitCode === "number" && Number.isInteger(value.exitCode) ? value.exitCode : null,
typeof value.output === "string" ? value.output : null);
}
}
if (typeof error !== "string") return null;
const match = LEGACY_MESSAGE.exec(error.trim());
if (!match) return null;
return normalize(LEGACY_STEPS[match[1]!]!, match[2]!, match[4] === undefined ? null : Number(match[4]), match[3] ?? null);
}
@@ -105,6 +105,15 @@ describe("gradeConfigTask", () => {
expect(grade.signature).toBe("CF1/provision_command:message_only_in_run_detail");
});
it("passes CF1 when the task interruption names the failed setup command", () => {
const parked = snapshot(90_000, { runs: [failedRun("r1", 10_000, "Execution workspace provision command \"bash ./scripts/provision-worktree.sh\" failed")],
interruption: { ...generic, setupFailure: "The project's workspace provision command \"bash ./scripts/provision-worktree.sh\" failed with exit code 127" } });
const grade = gradeConfigTask(record({}, { brokenSnapshots: [parked], parkedAt: 90_000, parkReason: "interruption",
restart: { kind: "K1", faultId: "f00", firedAt: 95_000, readyAt: 120_000, error: null }, afterRestartSnapshots: [parked], afterRestartParked: true,
repair: { at: 200_000, action: "clicked Retry", detail: "", error: null }, finalStatus: "done", doneAt: 400_000 }), options);
expect(grade.checks.find(check => check.name === "actionable_message")?.verdict).toBe("pass");
});
it("passes when the task names the setting, survives the restart and completes after repair", () => {
const parked = snapshot(90_000, { runs: [failedRun("r1", 10_000, "x")],
interruption: { ...generic, workspaceFailure: "The project workspace path is not available" } });
+4 -1
View File
@@ -168,7 +168,9 @@ export interface ConfigSnapshot {
reachable: boolean;
issueStatus: string | null;
runs: Array<{ id: string; status: string; createdAt: number | null; startedAt: number | null; errorCode: string | null; error: string | null }>;
interruption: { state: string | null; canRetry: boolean; userAction: string | null; workspaceFailure: string | null; observedRunId: string | null } | null;
interruption: { state: string | null; canRetry: boolean; userAction: string | null; workspaceFailure: string | null;
/** The named setup command failure (`setup_failed`), as the task page shows it. */
setupFailure?: string | null; observedRunId: string | null } | null;
execution: { label: string | null; cause: string | null; nextAction: string | null } | null;
/** Pending thread interactions on the task (connection cards and similar). */
cards: Array<{ id: string; kind: string; status: string; serviceSlug: string | null; agentSetup: boolean }>;
@@ -182,6 +184,7 @@ export function taskLevelTexts(snapshot: ConfigSnapshot): string[] {
const texts: string[] = [];
const interruption = snapshot.interruption;
if (interruption?.workspaceFailure) texts.push(interruption.workspaceFailure);
if (interruption?.setupFailure) texts.push(interruption.setupFailure);
if (interruption?.userAction) texts.push(`userAction:${interruption.userAction}`);
if (snapshot.execution?.label) texts.push(snapshot.execution.label);
if (snapshot.execution?.nextAction) texts.push(snapshot.execution.nextAction);
+1 -1
View File
@@ -233,7 +233,7 @@ async function snapshot(api: ChaosApi, issueId: string, agentId: string, errors:
return {
at, reachable: true, issueStatus: String(issue.status), runs: runSamples,
interruption: interruption ? { state: interruption.state ?? null, canRetry: interruption.canRetry === true, userAction: interruption.userAction ?? null,
workspaceFailure: interruption.workspaceFailure?.message ?? null, observedRunId: interruption.observedRunId ?? null } : null,
workspaceFailure: interruption.workspaceFailure?.message ?? null, setupFailure: interruption.setupFailure?.message ?? null, observedRunId: interruption.observedRunId ?? null } : null,
execution: projection ? { label: projection.label ?? null, cause: projection.cause ?? null, nextAction: projection.nextAction ?? null } : null,
cards: (Array.isArray(interactions) ? interactions : []).map(card => ({ id: String(card.id), kind: String(card.kind), status: String(card.status),
serviceSlug: card.payload?.serviceSlug ?? null, agentSetup: card.payload?.agentSetup === true })),
@@ -150,6 +150,18 @@ describe("ordinary task interruption", () => {
await render({ ...failed, workspaceFailure, state: "working" });
expect(container.textContent).toBe("");
});
it("names a failed workspace setup command and what to change, keeping Retry", async () => {
const setupFailure = { step: "workspace_provision" as const, command: "bash ./scripts/provision-worktree.sh", exitCode: 127,
output: "bash: ./scripts/provision-worktree.sh: No such file or directory",
message: "The project's workspace provision command \"bash ./scripts/provision-worktree.sh\" failed with exit code 127: bash: ./scripts/provision-worktree.sh: No such file or directory" };
await render({ ...failed, setupFailure });
expect(container.textContent).toContain("Couldn’t set up the task workspace.");
expect(container.textContent).toContain(setupFailure.message);
expect(container.textContent).toContain("Fix the command in the project’s workspace settings");
expect(container.textContent).not.toContain("Couldn’t finish this attempt.");
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(retry).toHaveBeenCalledOnce();
});
it("shows why an accepted Retry is waiting and keeps another attempt available", async () => {
const retryWait = { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." };
await render({ ...failed, retryWait });
+4 -1
View File
@@ -123,10 +123,13 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
)}>
{presentation.state === "failed" && <AlertCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />}
<span className="min-w-0 flex-1">{presentation.state === "retry_scheduled"
? step ? `${step}. ${retryIn}` : retryIn
? step ? `${step}. ${retryIn}`
: presentation.setupFailure ? `${presentation.setupFailure.message}. ${retryIn}` : retryIn
: presentation.retryWait ? presentation.retryWait.message
: presentation.workspaceFailure
? <>Couldn’t prepare the task folders. {presentation.workspaceFailure.message}</>
: presentation.setupFailure
? <>Couldn’t set up the task workspace. {presentation.setupFailure.message}. Fix the command in the project’s workspace settings, then retry.</>
: "Couldn’t finish this attempt. Your message is saved."}</span>
{canRetry && presentation.canRetry && <Button variant="outline" size="sm" className="shrink-0" onClick={onRetry} data-testid="task-execution-retry">Retry</Button>}
</div>