Files
PaperClipAI/ui/src/components/task-chat/TaskChatSystemNotice.tsx
T
DottaandPaperclip b6b3cb3761 Fix unavailable workspace branches with an inline repair and retry (#15726)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Isolated workspaces must resolve a starting Git ref before an agent
starts.
> - A task can explicitly select a branch that the repository does not
have.
> - The current recovery notice calls this a missing secret and offers
no branch repair.
> - This pull request explains the branch failure and adds a task-scoped
repair action.
> - Operators can correct the branch and request a guarded retry from
the task thread.

## Linked Issues or Issue Description

Related: Refs #11892 added unresolved-ref recovery. Refs #15719 added
bounded failure diagnostics. This change uses that recovery path and
adds its operator repair UI.

**What happened?**

A task that selects `main` in a repository with only `master` stops
before the adapter starts. The task shows a missing-secret message and a
generic retry. Repeating the run cannot fix the stored branch setting.

**Expected behavior**

Explain that the starting ref could not be resolved. Offer a verified
repository default, or let the operator enter a branch. Save only the
task's starting branch and retry its recorded owner through the existing
admission gates.

**Steps to reproduce**

1. Create an isolated-workspace task in a repository with `master` and
no `main`.
2. Set the task's base ref to `main` and assign an agent.
3. Wait for the configuration failure.
4. Use the inline branch repair and confirm the agent completes in a
worktree based on `master`.

## What Changed

- Replace the missing-secret diagnosis for unresolved workspace refs.
Keep uncertain fetch/access failures described as uncertain.
- Read the remote's advertised default branch with the existing Git
credentials and a bounded timeout. Do not guess or silently change the
branch.
- Add an atomic branch repair to the existing recovery resolution
endpoint. Check company access, board runtime permission, incident
identity, current owner, task/project state, dependencies, interactions,
approvals, budget, and active execution before requesting a retry.
- Preserve the effective inherited workspace setup when saving the task
branch override, and attach one repair notice per failed run even when
the incident is reused.
- Bind the shared repair card to the exact recovery notice and failed
run in both task-thread renderers. Validate branch syntax and prevent
duplicate submissions.
- Add Storybook examples, regression tests, and a documented local
acceptance fixture with a real Git repository and process adapter.

## Verification

- Red/green regressions cover the repair endpoint and preservation of
inherited agent setup. The regression failed before each fix and passed
afterward.
- Focused integration suite: 162 tests passed; task-thread/card suite:
230 passed. After review refinements, all 13 repair-route tests, 10 card
tests, 77 recovery-action tests, and the repeated-failure heartbeat
regression passed locally.
- Real browser acceptance passed for suggested-branch and custom-entry
repairs. Invalid syntax left the task blocked. Both valid repairs ran
the authenticated worker in a real isolated worktree and reached Done;
reload preserved the result.
- Repeated failures reused the recovery incident and retained an
actionable notice on the latest run. The inherited-setup journey
verified the provisioned file, agent branch template, worktree parent
directory, and actual Git commit before completing. [Before/after
screenshots](https://github.com/paperclipai/paperclip/pull/15726#issuecomment-6088734666).
- Full local typecheck and build passed; server typecheck passed again
after review fixes. Token gates passed.
- The monolithic local Vitest run was stopped after resource-contention
timeouts and stale assertions. Targeted regressions were rerun; the full
CI matrix passed on final revision
`26c543ff490bec29ddc77a49ee532c7f84fb96dc`. One signoff E2E
worker-cancellation failure passed on a rerun without code changes. The
two opt-in Storybook workflow jobs were skipped; the Storybook journey
was checked manually.
- Repeat the live journey with `tests/e2e/workspace-base-ref/README.md`.
Storybook examples use simulated callbacks and are not the live
acceptance proof.

## Risks

- Default-branch discovery can add up to 10 seconds to an unresolved-ref
failure. Lookup failure leaves manual entry available.
- A valid branch name can still be absent or inaccessible. The retried
workspace preparation remains authoritative and can block again with new
evidence.
- Retry remains subject to normal admission gates. The repair changes
only the task override; it does not alter project or agent defaults.
- No database migration or new dependency.

## Model Used

OpenAI GPT-6 through Codex, with tool execution, repository analysis,
and browser testing. The exact serving variant and context-window size
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>
2026-10-09 15:56:14 -05:00

179 lines
7.2 KiB
TypeScript

import { useWorkspaceBaseRefRecovery } from "@/components/WorkspaceBaseRefRecovery";
import { WorkspaceBaseRefRecoveryNotice } from "@/components/WorkspaceBaseRefRecoveryNotice";
import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "@/components/DispositionRecoveryNotice";
import { useId, useState } from "react";
import {
ChevronDown,
CircleCheck,
Info,
OctagonAlert,
TriangleAlert,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
import {
SystemNoticeMetadataSections,
type SystemNoticeTone,
} from "@/components/SystemNotice";
import { humanizeSystemNotice } from "@/lib/system-notice-humanizer";
import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment";
import { timeAgo } from "@/lib/timeAgo";
import type { TaskChatMessageItem } from "./task-chat-model";
const TONE_ICON: Record<SystemNoticeTone, LucideIcon> = {
neutral: Info,
info: Info,
success: CircleCheck,
warning: TriangleAlert,
danger: OctagonAlert,
};
const TONE_ICON_CLASS: Record<SystemNoticeTone, string> = {
neutral: "text-muted-foreground",
info: "text-(--status-task-icon-in_progress)",
success: "text-(--status-task-icon-done)",
warning: "text-(--status-task-icon-todo)",
danger: "text-(--status-task-icon-blocked)",
};
/**
* System comment row in the chat shell (PAP-443 / PAP-442 Phase 1): collapsed,
* it reads as a quiet left-aligned one-liner in TaskChatMarker's register — tone
* icon + humanized plain-English title + relative time + chevron — instead of
* a large gray paragraph of raw text. Expanding reveals the full
* markdown-rendered body plus any structured metadata sections. Workspace-ready
* notices omit their Markdown fallback when the equivalent structured workspace
* metadata is present, so each value appears once in the expanded panel.
*/
export function TaskChatSystemNotice({
item,
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending = false,
}: {
item: TaskChatMessageItem;
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
tryAgainNoLiveExecutionPathPending?: boolean;
}) {
const recoverySnapshot = useDispositionRecoverySnapshot(item.metadata);
const branchRecovery = useWorkspaceBaseRefRecovery(item.metadata);
const streamlined = useStreamlinedTaskChatPresentation();
const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen));
const detailsId = useId();
const { title, tone, detail } = humanizeSystemNotice({
body: item.text,
presentation: item.presentation,
});
const sections = mapCommentMetadataToSystemNoticeSections(item.metadata, {
runAgentId: item.runAgentId,
});
const isStructuredWorkspaceReadyNotice =
item.presentation?.kind === "system_notice" &&
item.presentation.title?.trim().toLowerCase().startsWith("workspace ready") === true &&
sections.some((section) => section.title?.trim().toLowerCase() === "workspace");
const showBody = !isStructuredWorkspaceReadyNotice;
const ToneIcon = TONE_ICON[tone];
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
const showTryAgain =
Boolean(onTryAgainNoLiveExecutionPath) &&
(item.presentation?.title?.trim() === "No live execution path" ||
item.text.toLowerCase().includes("no live execution path"));
const handleTryAgain = () => {
void Promise.resolve()
.then(() => onTryAgainNoLiveExecutionPath?.())
// The parent mutation owns visible error feedback.
.catch(() => undefined);
};
if (item.author === "system" && branchRecovery) return <WorkspaceBaseRefRecoveryNotice key={`${branchRecovery.actionId}:${branchRecovery.runId}`} {...branchRecovery.props} />;
if (item.author === "system" && recoverySnapshot) {
return <DispositionRecoveryNotice snapshot={recoverySnapshot} createdAt={item.createdAtIso} defaultExpanded={item.presentation?.detailsDefaultOpen} />;
}
return (
<div
className={cn("tc-enter-bubble flex flex-col items-start", streamlined ? "py-0.5" : "py-1")}
data-testid="task-chat-system-notice"
data-tone={streamlined ? tone : undefined}
role={streamlined ? "group" : undefined}
aria-label={streamlined ? `System update: ${title}` : undefined}
>
<div className="flex max-w-(--pct-85) items-center gap-1.5">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
"hover:bg-muted/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
<span className="truncate font-medium">{title}</span>
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
<ChevronDown
className={cn("tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-180")}
aria-hidden
/>
</button>
{showTryAgain && !open ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainNoLiveExecutionPathPending}
data-testid="task-chat-no-live-path-try-again"
>
{tryAgainNoLiveExecutionPathPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</div>
{open ? (
<div
id={detailsId}
data-testid="task-chat-system-notice-details"
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
{showBody ? (
<div className="px-3 py-2.5 text-foreground/90">
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
) : null}
{sections.length > 0 ? (
<div
className={cn(
"bg-background/50 dark:bg-background/30",
showBody && "border-t border-border/70",
)}
>
<SystemNoticeMetadataSections sections={sections} tone={tone} />
</div>
) : null}
{showTryAgain ? (
<div className="flex justify-end border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
<Button
type="button"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainNoLiveExecutionPathPending}
data-testid="task-chat-no-live-path-try-again"
>
{tryAgainNoLiveExecutionPathPending ? "Trying again..." : "Try again"}
</Button>
</div>
) : null}
</div>
) : null}
</div>
);
}