mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The control plane must keep each active issue on a clear execution or recovery path. > - A missing issue disposition can require more than one bounded repair attempt. > - A server restart could lose that repair path or move source ownership to the recovery owner. > - A parked or expired retry could also make the user interface show a false healthy state. > - Concurrent recovery loops must not schedule the same repair attempt twice. > - This pull request keeps retry state durable, makes scheduling atomic, and keeps source ownership stable. > - The benefit is that recovery continues after a restart and operators see the correct state. ## Linked Issues or Issue Description **What happened?** A run that ended without a valid issue disposition could lose its repair path after a server restart. Manager recovery could also change the source owner. In addition, a parked or expired retry could make the issue look healthy when no active work existed. Concurrent reconciliation could also schedule the same repair attempt twice. **Expected behavior** Paperclip must keep bounded source and manager repair attempts across restarts. Recovery ownership must stay separate from source issue ownership. The server and user interface must report only a live retry as active work. Each repair attempt must be scheduled at most once per company. **Steps to reproduce** 1. Start an agent run on an issue. 2. End the run without a valid issue disposition. 3. Let the first repair attempt schedule a retry. 4. Restart the server, let the retry time pass without a live run, or start two reconciliation loops together. 5. Observe that the repair path can stop, the issue can show a false healthy state, or duplicate retries can be created. **Paperclip version or commit** The problem existed on `master` before candidate head `d8e620fe86bade7df18decac332007f5821ae04f`. **Deployment mode** The problem affects self-hosted servers and local builds that use automatic recovery. ## What Changed - Persist bounded source-owner and manager repair lineages with stable fingerprints and retry limits. - Resume incomplete disposition repairs after a server restart. - Keep recovery ownership separate from source issue ownership and enforce source mutation authority. - Project live retry evidence into issue and blocker summaries. - Show recovery owner, return owner, attempt count, and retry state in the board user interface. - Treat expired or parked retries as attention states unless a queued or running attempt exists. - Atomically deduplicate disposition-repair wake requests with a company-scoped partial unique index. - Reuse the winning run when concurrent reconciliation loses the uniqueness race, without duplicate scheduling activity. - Honor disabled on-demand wake policy before recovery scheduling and again before delayed retry promotion. - Keep the new index migration safe for lagging seeded databases that already contain the index. - Add server and user interface tests for recovery, restart, ownership, retry, concurrency, and blocker states. - Update the implementation and execution semantics documents. ## Verification - Focused server recovery and ownership suites: 282 tests passed on the repaired base candidate. - Focused user interface recovery suites: 128 tests passed on the repaired base candidate. - Atomic-deduplication schema and recovery suites: 111 tests passed on the first Greptile repair. - Recovery and scheduled-retry wake-policy suites: 126 tests passed at `d8e620fe86bade7df18decac332007f5821ae04f`. - The exact lagging-source migration-order test passed after the index migration became idempotent: 1 test passed and 62 unrelated tests were skipped. - `@paperclipai/db` and `@paperclipai/server` typechecks passed at the current head. - Migration generation and migration safety checks passed for migration `0226_tan_colossus.sql`. - `pnpm check:token-gates` passed on the repaired base candidate. - `pnpm -r typecheck` passed on the repaired base candidate. - `pnpm build` passed on the repaired base candidate. - `pnpm test:run` passed 4,540 tests on the repaired base candidate. Four fixed-port cases met listeners that already existed on the host. - The two unchanged fixed-port files passed in an isolated network namespace: 129 tests passed and 27 tests were skipped. - Independent Security and QA reviews approved `63c0423aab54c66f2293a20b0fb3f3b013ee3ba8`; exact-head re-review is required after automated checks settle on `d8e620fe86bade7df18decac332007f5821ae04f`. ## Risks - Recovery orchestration affects issue liveness and ownership. The new paths use bounded attempts, stable fingerprints, row locks, authority checks, and database uniqueness. - A conservative attention state can show more warnings when a scheduled retry has no queued or running attempt. It does not hide stopped work. - Migration `0226_tan_colossus.sql` creates a partial unique index on a known-large table. Migrations run transactionally, so `CONCURRENTLY` is unavailable. The matching disposition-repair key namespace is introduced by this release, so deployed databases have no matching rows before the index is added. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex from the GPT-5 model family used agentic reasoning, tool use, and code execution. The runtime did not expose the exact model ID or context window. - Anthropic Claude Opus 5 used a 1M context window, tool use, and code execution for part of the user interface repair, as recorded in the commit history. ## 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> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
722 lines
31 KiB
TypeScript
722 lines
31 KiB
TypeScript
import type {
|
|
IssueBlockerAttention,
|
|
IssueRecoveryAction,
|
|
IssueRelationIssueSummary,
|
|
IssueScheduledRetry,
|
|
SuccessfulRunHandoffState,
|
|
} from "@paperclipai/shared";
|
|
import type { ReactNode } from "react";
|
|
import { AlertTriangle, CheckCircle2, Circle, Flag, Loader2, RotateCcw } from "lucide-react";
|
|
import { Link } from "@/lib/router";
|
|
import { cn } from "../lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
|
import { formatMonitorOffset } from "../lib/issue-monitor";
|
|
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
|
|
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
|
import { RetryErrorBand } from "./IssueScheduledRetryCard";
|
|
import {
|
|
isAssignedBacklogBlocker,
|
|
orderWaitingBlockers,
|
|
type WaitingBlockerStatus,
|
|
} from "../lib/issue-blockers";
|
|
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
deriveActiveRecoveryDisplayState,
|
|
RECOVERY_CHIP_DEFAULT_TONE,
|
|
recoveryChipLabel,
|
|
} from "../lib/recovery-display";
|
|
import {
|
|
formatRecoveryLineageSummary,
|
|
readRecoveryRetryLineage,
|
|
} from "../lib/recovery-lineage";
|
|
import { StatusGlyph } from "./StatusGlyph";
|
|
|
|
function BlockerRecoveryIndicator({
|
|
action,
|
|
scheduledRetry,
|
|
}: {
|
|
action: IssueRecoveryAction;
|
|
/** The blocker's own scheduled retry, used to verify that the stored attempt is in flight. */
|
|
scheduledRetry?: IssueScheduledRetry | null;
|
|
}) {
|
|
const liveness = { scheduledRetry: scheduledRetry ?? null };
|
|
const state = deriveActiveRecoveryDisplayState(action, liveness);
|
|
if (!state) return null;
|
|
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
|
|
const Icon = tone.icon;
|
|
// The blocker chip reads the same stored lineage as the source task's recovery card, so
|
|
// a parent view never contradicts the task it is waiting on.
|
|
const lineage = readRecoveryRetryLineage(action, liveness);
|
|
const label = recoveryChipLabel(state, action.kind, lineage);
|
|
const detail = lineage ? formatRecoveryLineageSummary(lineage) : null;
|
|
return (
|
|
<Badge variant="outline"
|
|
data-testid="issue-blocked-notice-recovery-indicator"
|
|
data-recovery-state={state}
|
|
data-recovery-kind={action.kind}
|
|
data-recovery-lane={lineage?.lane}
|
|
role="status"
|
|
aria-label={detail ? `${label} — ${detail}` : label}
|
|
title={detail
|
|
? `${label} — ${detail}. Open the source task to act.`
|
|
: `${label} — open the source task to act.`}
|
|
className={`[&>svg]:size-2.5 gap-0.5 px-1.5 text-(length:--text-nano) ${tone.className}`}
|
|
>
|
|
<Icon className="h-2.5 w-2.5" aria-hidden />
|
|
{label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function SuccessfulRunRetryNowControl({
|
|
issueId,
|
|
scheduledRetry,
|
|
}: {
|
|
issueId: string;
|
|
scheduledRetry: IssueScheduledRetry;
|
|
}) {
|
|
const retryNow = useRetryNowMutation(issueId);
|
|
const dueAtIso = scheduledRetry.scheduledRetryAt
|
|
? new Date(scheduledRetry.scheduledRetryAt).toISOString()
|
|
: null;
|
|
const relative = dueAtIso ? formatMonitorOffset(dueAtIso) : null;
|
|
const scheduleLabel = relative === "now"
|
|
? "due now"
|
|
: relative
|
|
? `scheduled ${relative}`
|
|
: "scheduled";
|
|
const success = retryNow.isSuccess
|
|
&& (retryNow.data?.outcome === "promoted" || retryNow.data?.outcome === "already_promoted");
|
|
|
|
return (
|
|
<div className="mt-2 rounded-md border border-amber-300/70 bg-background/80 p-2 dark:border-amber-500/40 dark:bg-background/40">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0 text-xs leading-5 text-amber-900 dark:text-amber-100">
|
|
Paperclip will ask the assignee to choose the next step {scheduleLabel}. Retry now starts that follow-up immediately.
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0 border-amber-300/80 bg-background/80 text-amber-950 shadow-none hover:bg-amber-100 dark:border-amber-500/50 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
|
onClick={() => retryNow.mutate()}
|
|
disabled={retryNow.isPending || success}
|
|
data-testid="issue-next-step-retry-now"
|
|
>
|
|
{retryNow.isPending ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
Retrying...
|
|
</span>
|
|
) : success ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{retryNow.data?.outcome === "already_promoted" ? "Already promoted" : "Promoted"}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Retry now
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<RetryErrorBand
|
|
error={retryNow.lastError}
|
|
className="mt-2 border-amber-300/70 bg-amber-100/70 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-100"
|
|
onRetry={() => {
|
|
retryNow.reset();
|
|
retryNow.mutate();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EMPTY_LIVE_IDS: ReadonlySet<string> = new Set<string>();
|
|
|
|
function waitingTaskStatusLabel(status: string): string {
|
|
return status.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function WaitingChipLink({
|
|
blocker,
|
|
running = false,
|
|
}: {
|
|
blocker: IssueRelationIssueSummary;
|
|
running?: boolean;
|
|
}) {
|
|
const issuePathId = blocker.identifier ?? blocker.id;
|
|
return (
|
|
<IssueLinkQuicklook
|
|
issuePathId={issuePathId}
|
|
to={createIssueDetailPath(issuePathId)}
|
|
className="inline-flex max-w-full items-center gap-1 rounded-md border border-blue-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-blue-950 transition-colors hover:border-blue-500 hover:bg-blue-100 hover:underline dark:border-blue-500/40 dark:bg-background/40 dark:text-blue-100 dark:hover:bg-blue-500/15"
|
|
>
|
|
<StatusGlyph
|
|
status={blocker.status}
|
|
size="sm"
|
|
title={`${waitingTaskStatusLabel(blocker.status)} status`}
|
|
/>
|
|
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
|
<span className="max-w-(--sz-18rem) truncate font-sans text-(length:--text-micro) text-blue-800 dark:text-blue-200">
|
|
{blocker.title}
|
|
</span>
|
|
{running ? (
|
|
<span className="ml-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-(length:--text-nano) font-medium uppercase tracking-wide text-blue-700 dark:bg-blue-400/20 dark:text-blue-200">
|
|
running
|
|
</span>
|
|
) : null}
|
|
</IssueLinkQuicklook>
|
|
);
|
|
}
|
|
|
|
function WaitingStepGlyph({ status }: { status: WaitingBlockerStatus }) {
|
|
if (status === "done") {
|
|
return <CheckCircle2 className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" aria-hidden />;
|
|
}
|
|
if (status === "running") {
|
|
return (
|
|
<span className="flex h-3.5 w-3.5 items-center justify-center" aria-hidden>
|
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
|
|
</span>
|
|
);
|
|
}
|
|
return <Circle className="h-3.5 w-3.5 text-blue-300 dark:text-blue-500/50" aria-hidden />;
|
|
}
|
|
|
|
/**
|
|
* Calm in-flight counterpart to the amber "still needs a next step" alarm.
|
|
* The handoff is still `required`, but a correction run is live on the issue,
|
|
* so the alarm would be crying wolf while an agent is already working. Saying
|
|
* it quietly beats saying nothing: the reader still learns a disposition is
|
|
* outstanding, and learns that the alarm comes back if the run ends without
|
|
* choosing one.
|
|
*/
|
|
function SuccessfulRunHandoffInFlightNotice({
|
|
liveRunId,
|
|
assigneeAgentId,
|
|
}: {
|
|
liveRunId?: string | null;
|
|
assigneeAgentId?: string | null;
|
|
}) {
|
|
const shortRunId = liveRunId ? liveRunId.slice(0, 8) : null;
|
|
return (
|
|
<div
|
|
data-testid="issue-next-step-in-flight"
|
|
data-successful-run-handoff="in_flight"
|
|
className="mb-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="mt-1 flex h-3.5 w-3.5 shrink-0 items-center justify-center" aria-hidden>
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-400" />
|
|
</span>
|
|
<p className="min-w-0 leading-5">
|
|
A correction run is in progress — the agent is working. This alert returns if the run
|
|
stops without choosing a next step.
|
|
{shortRunId ? (
|
|
<>
|
|
{" "}
|
|
{assigneeAgentId ? (
|
|
<Link
|
|
to={`/agents/${assigneeAgentId}/runs/${liveRunId}`}
|
|
className="font-mono underline underline-offset-2 hover:text-foreground"
|
|
>
|
|
run {shortRunId}
|
|
</Link>
|
|
) : (
|
|
<span className="font-mono">run {shortRunId}</span>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Blue "Waiting on live work" variant — rendered in place of the
|
|
* amber notice when `blockerAttention.state === "covered"`: the blocker chain
|
|
* is a healthy plan executing in order and something in it is live.
|
|
*/
|
|
function WaitingOnLiveWorkNotice({
|
|
blockerAttentionState,
|
|
chainBlockers,
|
|
terminalBlockers,
|
|
liveIds,
|
|
parkedBlockers,
|
|
renderParkedChip,
|
|
}: {
|
|
blockerAttentionState?: string;
|
|
chainBlockers: IssueRelationIssueSummary[];
|
|
terminalBlockers: IssueRelationIssueSummary[];
|
|
liveIds: ReadonlySet<string>;
|
|
parkedBlockers: IssueRelationIssueSummary[];
|
|
renderParkedChip: (blocker: IssueRelationIssueSummary) => ReactNode;
|
|
}) {
|
|
const steps = orderWaitingBlockers(chainBlockers, liveIds);
|
|
const total = steps.length;
|
|
const doneCount = steps.filter((step) => step.status === "done").length;
|
|
const runningCount = steps.filter((step) => step.status === "running").length;
|
|
|
|
// "Now running" replaces "Ultimately waiting on": prefer live terminal
|
|
// leaves that are not already shown in the ordered queue list.
|
|
const stepIds = new Set(steps.map((step) => step.blocker.id));
|
|
const nowRunningSeen = new Set<string>();
|
|
const nowRunning: IssueRelationIssueSummary[] = [];
|
|
for (const blocker of [...terminalBlockers, ...chainBlockers]) {
|
|
if (!liveIds.has(blocker.id)) continue;
|
|
if (stepIds.has(blocker.id)) continue;
|
|
if (nowRunningSeen.has(blocker.id)) continue;
|
|
nowRunningSeen.add(blocker.id);
|
|
nowRunning.push(blocker);
|
|
}
|
|
|
|
const queuedNoun = total === 1 ? "task" : "tasks";
|
|
|
|
return (
|
|
<div
|
|
data-blocker-attention-state={blockerAttentionState}
|
|
data-testid="issue-blocked-notice-live"
|
|
className="mb-3 rounded-md border border-blue-300/70 bg-blue-50/90 px-3 py-2.5 text-sm text-blue-950 shadow-sm dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-100"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="mt-1.5 flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
|
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-400" />
|
|
</span>
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="space-y-1">
|
|
<p className="font-medium leading-5">Waiting on live work</p>
|
|
<p className="leading-5">
|
|
Queued behind {total} {queuedNoun} being worked in order. This task
|
|
resumes automatically when the chain is done. Comments still notify the
|
|
assignee.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1" data-testid="issue-blocked-notice-progress">
|
|
<div className="text-xs font-medium text-blue-800 dark:text-blue-200">
|
|
{doneCount} of {total} done
|
|
{runningCount > 0 ? ` · ${runningCount} running` : null}
|
|
</div>
|
|
<div
|
|
role="progressbar"
|
|
aria-label="Blocker chain progress"
|
|
aria-valuemin={0}
|
|
aria-valuenow={doneCount}
|
|
aria-valuemax={total}
|
|
className="flex h-2 w-full overflow-hidden rounded-full bg-blue-100 dark:bg-blue-500/20"
|
|
>
|
|
{steps.map(({ blocker, status }) => (
|
|
<span
|
|
key={blocker.id}
|
|
className={cn(
|
|
"h-full border-r border-blue-50/80 last:border-r-0 dark:border-blue-950/40",
|
|
status === "done"
|
|
? "bg-blue-500 dark:bg-blue-400"
|
|
: status === "running"
|
|
? "animate-pulse bg-blue-400"
|
|
: "bg-blue-200 dark:bg-blue-500/30",
|
|
)}
|
|
style={{ width: `${100 / total}%` }}
|
|
title={`${blocker.identifier ?? blocker.id.slice(0, 8)}: ${status}`}
|
|
aria-hidden
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div data-testid="issue-blocked-notice-steps">
|
|
{steps.map(({ blocker, status }) => (
|
|
<div key={blocker.id} className="flex items-stretch gap-2">
|
|
<div className="flex w-3.5 flex-col items-center">
|
|
<span className="flex min-h-6 items-center">
|
|
<WaitingStepGlyph status={status} />
|
|
</span>
|
|
<span
|
|
className="w-px flex-1 bg-blue-300/50 dark:bg-blue-500/30"
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 pb-1.5">
|
|
<WaitingChipLink blocker={blocker} running={status === "running"} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="flex items-stretch gap-2">
|
|
<div className="flex w-3.5 flex-col items-center">
|
|
<span
|
|
className="mt-1.5 h-3 w-3 rounded-full border border-dashed border-blue-400/60 dark:border-blue-400/50"
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 pb-0.5">
|
|
<span className="inline-block rounded-md border border-dashed border-blue-300/70 px-2 py-1 text-xs text-blue-800 dark:border-blue-500/40 dark:text-blue-200">
|
|
This task — resumes automatically when the chain is done
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{nowRunning.length > 0 ? (
|
|
<div
|
|
data-testid="issue-blocked-notice-now-running"
|
|
className="space-y-1 pt-0.5"
|
|
>
|
|
<div className="text-xs font-medium text-blue-800 dark:text-blue-200">
|
|
Now running
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{nowRunning.map((blocker) => (
|
|
<WaitingChipLink key={blocker.id} blocker={blocker} running />
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{parkedBlockers.length > 0 ? (
|
|
<div
|
|
data-testid="issue-blocked-notice-parked-row"
|
|
className="flex flex-wrap items-center gap-1.5 pt-0.5"
|
|
>
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
<Flag className="h-3 w-3" aria-hidden />
|
|
Blocked by parked work
|
|
</span>
|
|
{parkedBlockers.map((blocker) => renderParkedChip(blocker))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IssueBlockedNotice({
|
|
issueId,
|
|
issueStatus,
|
|
blockers,
|
|
allBlockers,
|
|
liveIssueIds,
|
|
blockerAttention,
|
|
successfulRunHandoff,
|
|
scheduledRetry,
|
|
agentName,
|
|
}: {
|
|
issueId?: string | null;
|
|
issueStatus?: string;
|
|
/** Unresolved blockers (drives the amber notice; unchanged). */
|
|
blockers: IssueRelationIssueSummary[];
|
|
/**
|
|
* Full blocker list (resolved + unresolved). Used by the blue "Waiting on
|
|
* live work" variant to render done steps and progress counts. Falls back to
|
|
* {@link blockers} when not supplied.
|
|
*/
|
|
allBlockers?: IssueRelationIssueSummary[];
|
|
/** Company-wide set of issue ids with a queued/running run (own or blocker). */
|
|
liveIssueIds?: ReadonlySet<string>;
|
|
blockerAttention?: IssueBlockerAttention | null;
|
|
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
|
scheduledRetry?: IssueScheduledRetry | null;
|
|
agentName?: string | null;
|
|
}) {
|
|
if (issueStatus === "done" || issueStatus === "cancelled") return null;
|
|
// A live run on this issue means an agent is already handling it — the
|
|
// missing-disposition complaint only applies when the issue is stuck.
|
|
// `hasLiveContinuation` is the server's view; `liveIssueIds` catches runs
|
|
// that started after the issue payload was fetched.
|
|
const issueHasLiveRun = Boolean(issueId && liveIssueIds?.has(issueId));
|
|
const showSuccessfulRunHandoff =
|
|
successfulRunHandoff != null
|
|
&& isSuccessfulRunHandoffRequired({ successfulRunHandoff, scheduledRetry })
|
|
&& !issueHasLiveRun;
|
|
// Outstanding handoff + a live run on the issue: the alarm is suppressed, so
|
|
// render the quiet in-flight line in its place rather than nothing at all.
|
|
// The unpromoted-scheduled-retry carve-out keeps `showSuccessfulRunHandoff`
|
|
// true, so the amber notice (and its "Retry now" control) still wins there.
|
|
// This stands in for the handoff alarm only. When the issue also has
|
|
// blockers, the blocker notice below is the stronger signal and owns the
|
|
// slot, exactly as it did before this line existed.
|
|
const handoffInFlightNotice =
|
|
successfulRunHandoff != null
|
|
&& successfulRunHandoff.required === true
|
|
&& !showSuccessfulRunHandoff
|
|
&& (successfulRunHandoff.hasLiveContinuation || issueHasLiveRun)
|
|
? (
|
|
<SuccessfulRunHandoffInFlightNotice
|
|
liveRunId={successfulRunHandoff.liveRunId}
|
|
assigneeAgentId={successfulRunHandoff.assigneeAgentId}
|
|
/>
|
|
)
|
|
: null;
|
|
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") {
|
|
return handoffInFlightNotice;
|
|
}
|
|
const successfulRunRetryNow = showSuccessfulRunHandoff
|
|
&& issueId
|
|
&& scheduledRetry?.status === "scheduled_retry"
|
|
? { issueId, scheduledRetry }
|
|
: null;
|
|
|
|
const blockerLabel = blockers.length === 1 ? "the linked task" : "the linked tasks";
|
|
const terminalBlockers = blockers
|
|
.flatMap((blocker) => blocker.terminalBlockers ?? [])
|
|
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
|
|
|
|
const isStalled = blockerAttention?.state === "stalled";
|
|
const parkedBlockers = (() => {
|
|
const seen = new Set<string>();
|
|
const collected: IssueRelationIssueSummary[] = [];
|
|
const sources: IssueRelationIssueSummary[] = [...blockers];
|
|
for (const blocker of blockers) {
|
|
for (const terminal of blocker.terminalBlockers ?? []) {
|
|
sources.push(terminal);
|
|
}
|
|
}
|
|
for (const blocker of sources) {
|
|
if (!isAssignedBacklogBlocker(blocker)) continue;
|
|
if (seen.has(blocker.id)) continue;
|
|
seen.add(blocker.id);
|
|
collected.push(blocker);
|
|
}
|
|
return collected;
|
|
})();
|
|
const showParkedRow = parkedBlockers.length > 0;
|
|
const stalledLeafIdentifier =
|
|
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
|
|
const stalledLeafBlockers = (() => {
|
|
const candidates: IssueRelationIssueSummary[] = [];
|
|
for (const blocker of [...blockers, ...terminalBlockers]) {
|
|
if (blocker.status !== "in_review") continue;
|
|
if (candidates.some((existing) => existing.id === blocker.id)) continue;
|
|
candidates.push(blocker);
|
|
}
|
|
if (stalledLeafIdentifier) {
|
|
const preferred = candidates.find(
|
|
(blocker) => (blocker.identifier ?? blocker.id) === stalledLeafIdentifier,
|
|
);
|
|
if (preferred) {
|
|
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
|
|
}
|
|
}
|
|
return candidates;
|
|
})();
|
|
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
|
|
|
|
// Rule C (PAP-13554 / plan §Rule C): when the issue is `blocked` and a
|
|
// blocker edge is genuinely not done, a human comment does NOT reopen it —
|
|
// the reopen gate keeps it blocked. `blockers` here is the *unresolved* set
|
|
// (status ≠ done/cancelled), so a non-empty list on a `blocked` issue is
|
|
// exactly the case the human's message can't move to todo. Done-but-pending-
|
|
// finalize blockers are `done`, so they fall out of this set and into the
|
|
// Rule B reopen path — we must not claim "a message won't reopen" for those.
|
|
// Name the deepest unresolved leaf (prefer terminal leaves) with its status
|
|
// so "I sent a message and nothing happened" can't recur silently.
|
|
const responsibleName = agentName ?? "the assignee";
|
|
const reopenSuppressed = issueStatus === "blocked" && !isStalled && blockers.length > 0;
|
|
const unresolvedLeafBlockers = (() => {
|
|
if (!reopenSuppressed) return [] as IssueRelationIssueSummary[];
|
|
const seen = new Set<string>();
|
|
const collected: IssueRelationIssueSummary[] = [];
|
|
for (const blocker of blockers) {
|
|
const terminals = (blocker.terminalBlockers ?? []).filter(
|
|
(leaf) => leaf.status !== "done" && leaf.status !== "cancelled",
|
|
);
|
|
const leaves = terminals.length > 0 ? terminals : [blocker];
|
|
for (const leaf of leaves) {
|
|
if (seen.has(leaf.id)) continue;
|
|
seen.add(leaf.id);
|
|
collected.push(leaf);
|
|
}
|
|
}
|
|
return collected;
|
|
})();
|
|
const reopenSuppressedLeaf = unresolvedLeafBlockers[0] ?? null;
|
|
const reopenSuppressedLeafId = reopenSuppressedLeaf
|
|
? reopenSuppressedLeaf.identifier ?? reopenSuppressedLeaf.id.slice(0, 8)
|
|
: null;
|
|
const reopenSuppressedLeafStatus = reopenSuppressedLeaf
|
|
? reopenSuppressedLeaf.status.replace(/_/g, " ")
|
|
: null;
|
|
const reopenSuppressedOtherCount = Math.max(unresolvedLeafBlockers.length - 1, 0);
|
|
|
|
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
|
const issuePathId = blocker.identifier ?? blocker.id;
|
|
const recoveryAction = blocker.activeRecoveryAction ?? null;
|
|
return (
|
|
<IssueLinkQuicklook
|
|
key={blocker.id}
|
|
issuePathId={issuePathId}
|
|
to={createIssueDetailPath(issuePathId)}
|
|
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
|
>
|
|
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
|
<span className="max-w-(--sz-18rem) truncate font-sans text-(length:--text-micro) text-amber-800 dark:text-amber-200">
|
|
{blocker.title}
|
|
</span>
|
|
{recoveryAction ? (
|
|
<BlockerRecoveryIndicator
|
|
action={recoveryAction}
|
|
scheduledRetry={blocker.scheduledRetry}
|
|
/>
|
|
) : null}
|
|
</IssueLinkQuicklook>
|
|
);
|
|
};
|
|
|
|
// Blue "Waiting on live work" variant: the blocker chain is a healthy plan
|
|
// executing in order and something in it is live. `covered` is
|
|
// the only state that goes blue — stalled / needs_attention / none keep the
|
|
// amber notice byte-for-byte. The successful-run handoff notice is about this
|
|
// task's own finished run, so it always keeps its amber priority styling.
|
|
const liveIds = liveIssueIds ?? EMPTY_LIVE_IDS;
|
|
const chainBlockers = allBlockers ?? blockers;
|
|
const hasLiveWaitingBlocker = [...chainBlockers, ...terminalBlockers].some((blocker) => (
|
|
liveIds.has(blocker.id)
|
|
));
|
|
const waitingOnLiveWork =
|
|
!showSuccessfulRunHandoff
|
|
&& blockerAttention?.state === "covered"
|
|
&& chainBlockers.length > 0
|
|
&& hasLiveWaitingBlocker;
|
|
|
|
if (waitingOnLiveWork) {
|
|
return (
|
|
<WaitingOnLiveWorkNotice
|
|
blockerAttentionState={blockerAttention?.state}
|
|
chainBlockers={chainBlockers}
|
|
terminalBlockers={terminalBlockers}
|
|
liveIds={liveIds}
|
|
parkedBlockers={showParkedRow ? parkedBlockers : []}
|
|
renderParkedChip={renderBlockerChip}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-blocker-attention-state={blockerAttention?.state}
|
|
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
|
|
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
|
<div className="min-w-0 space-y-1.5">
|
|
{showSuccessfulRunHandoff ? (
|
|
<>
|
|
<p className="font-medium leading-5">This task still needs a next step.</p>
|
|
<p className="leading-5">
|
|
A run finished successfully, but the task is still open. Paperclip needs someone to choose
|
|
what happens next.
|
|
</p>
|
|
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
|
|
<li>Mark it done or cancelled.</li>
|
|
<li>Send it for review or ask for input.</li>
|
|
<li>Record what is blocking it and who owns that blocker.</li>
|
|
<li>Delegate follow-up work or queue a continuation.</li>
|
|
</ul>
|
|
<div className="flex flex-wrap gap-1.5 text-xs">
|
|
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
|
|
<Link
|
|
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
|
|
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
|
|
>
|
|
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
|
|
</Link>
|
|
) : successfulRunHandoff.sourceRunId ? (
|
|
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
|
|
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
|
|
</span>
|
|
) : null}
|
|
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
|
|
Asked {agentName ?? "the assignee"} to choose the next step
|
|
</span>
|
|
</div>
|
|
{successfulRunHandoff.detectedProgressSummary ? (
|
|
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
|
|
Detected progress: {successfulRunHandoff.detectedProgressSummary}
|
|
</p>
|
|
) : null}
|
|
{successfulRunRetryNow ? (
|
|
<SuccessfulRunRetryNowControl
|
|
issueId={successfulRunRetryNow.issueId}
|
|
scheduledRetry={successfulRunRetryNow.scheduledRetry}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
|
|
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
|
|
) : null}
|
|
{blockers.length > 0 || issueStatus === "blocked" ? (
|
|
<>
|
|
<p className="leading-5">
|
|
{blockers.length > 0
|
|
? isStalled
|
|
? stalledLeafBlockers.length > 1
|
|
? <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
|
: <>Work on this task is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
|
: reopenSuppressed
|
|
? <>A message won’t restart this task yet — it stays blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} done, then it reopens automatically. Comments still notify {responsibleName} for questions or triage in the meantime.</>
|
|
: <>Work on this task is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still notify the assignee for questions or triage.</>
|
|
: <>Work on this task is blocked until someone moves it back to To do. Comments still notify the assignee for questions or triage.</>}
|
|
</p>
|
|
{reopenSuppressed && reopenSuppressedLeafId ? (
|
|
<p
|
|
data-testid="issue-blocked-notice-reopen-suppressed"
|
|
className="text-xs font-medium leading-5 text-amber-900 dark:text-amber-100"
|
|
>
|
|
Still blocked by{" "}
|
|
<span className="font-mono">{reopenSuppressedLeafId}</span>
|
|
{reopenSuppressedLeafStatus ? <> ({reopenSuppressedLeafStatus})</> : null}
|
|
{reopenSuppressedOtherCount > 0
|
|
? ` and ${reopenSuppressedOtherCount} other ${
|
|
reopenSuppressedOtherCount === 1 ? "task" : "tasks"
|
|
}`
|
|
: null}
|
|
.
|
|
</p>
|
|
) : null}
|
|
{blockers.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{blockers.map(renderBlockerChip)}
|
|
</div>
|
|
) : null}
|
|
{showStalledRow ? (
|
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
|
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
Stalled in review
|
|
</span>
|
|
{stalledLeafBlockers.map(renderBlockerChip)}
|
|
</div>
|
|
) : terminalBlockers.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
|
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
Ultimately waiting on
|
|
</span>
|
|
{terminalBlockers.map(renderBlockerChip)}
|
|
</div>
|
|
) : null}
|
|
{showParkedRow ? (
|
|
<div
|
|
data-testid="issue-blocked-notice-parked-row"
|
|
className="flex flex-wrap items-center gap-1.5 pt-0.5"
|
|
>
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
<Flag className="h-3 w-3" aria-hidden />
|
|
Blocked by parked work
|
|
</span>
|
|
{parkedBlockers.map(renderBlockerChip)}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|