Files
paperclip/ui/src/components/IssueRunLedger.tsx
T
Dotta 15eac43b43 [codex] Retry max-turn exhausted heartbeats (#5096)
## Thinking Path

> - Paperclip orchestrates AI agents for autonomous companies, and
heartbeat execution is the control-plane loop that keeps assigned work
moving.
> - Max-turn exhaustion is a recoverable local-adapter stop condition
for Claude and Gemini agents when a run needs another heartbeat to
continue safely.
> - The previous behavior could leave max-turn continuation details hard
to inspect, and duplicate/stale continuation wakes could keep running
after issue state changed.
> - The adapter layer also needed to avoid trusting arbitrary
stdout/stderr text as scheduler control metadata.
> - This pull request adds bounded max-turn continuation scheduling,
visible retry state, structured stop metadata handling, and
stale/duplicate continuation guards.
> - The benefit is safer automatic continuation after max-turn stops,
clearer operator visibility, and fewer duplicate or stale agent runs.

## What Changed

- Replaces closed PR #4952, whose head repository was deleted.
- Rebases the recovered max-turn continuation branch onto current
`paperclipai/paperclip:master`.
- Adds max-turn continuation scheduling and retry-state plumbing for
heartbeat runs.
- Adds stale/duplicate continuation suppression when issue status,
ownership, or execution locks change.
- Normalizes Claude/Gemini max-turn detection around structured stop
metadata instead of unstructured stdout/stderr text.
- Surfaces max-turn continuation settings and retry visibility in the
board UI.
- Adds focused server, adapter, and UI tests for max-turn stop metadata,
retry scheduling, stale queued-run invalidation, adapter
parsing/execution, run ledger display, and agent config patching.

## Verification

- `pnpm install --no-frozen-lockfile` to refresh local dependencies
after rebasing onto current `master`.
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/claude-local-adapter.test.ts
server/src/__tests__/claude-local-execute.test.ts
server/src/__tests__/gemini-local-adapter.test.ts
server/src/__tests__/gemini-local-execute.test.ts
server/src/__tests__/heartbeat-retry-scheduling.test.ts
server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts
server/src/services/heartbeat-stop-metadata.test.ts
ui/src/components/IssueRunLedger.test.tsx
ui/src/lib/agent-config-patch.test.ts ui/src/lib/runRetryState.test.ts
--testTimeout=20000`
- `pnpm --filter @paperclipai/adapter-claude-local typecheck && pnpm
--filter @paperclipai/adapter-gemini-local typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck`
- UI screenshot note: the UI changes are limited to config/ledger state
rendering rather than layout changes; component/unit coverage above
verifies the rendered behavior.

## Risks

- Medium behavior risk: heartbeat retry gating now suppresses max-turn
continuations when issue state or execution locks drift, so any callers
that relied on stale continuations running will now see cancellation
instead.
- Low adapter risk: Claude/Gemini unstructured text no longer triggers
max-turn scheduler metadata, so only structured stop signals and Gemini
exit code 53 are trusted.
- No database migrations.

> 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 coding agent, GPT-5-class model, tool-enabled local
repository editing and command execution.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (not applicable: state/default rendering only; covered by
component/unit tests)
- [x] I have updated relevant documentation to reflect my changes (not
applicable: no user-facing command or docs contract changed)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-03 11:30:48 -05:00

851 lines
33 KiB
TypeScript

import { useMemo, useState, type ReactNode } from "react";
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { accessApi, type CurrentBoardAccess } from "../api/access";
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
import { ApiError } from "../api/client";
import {
heartbeatsApi,
type ActiveRunForIssue,
type LiveRunForIssue,
type WatchdogDecisionInput,
} from "../api/heartbeats";
import { useToastActions } from "../context/ToastContext";
import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
import { describeRunRetryState } from "../lib/runRetryState";
type IssueRunLedgerProps = {
issueId: string;
companyId: string;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: ReadonlyMap<string, Agent>;
hasLiveRuns: boolean;
activityEvents?: ActivityEvent[];
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
};
type IssueRunLedgerContentProps = {
runs: RunForIssue[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
activityEvents?: ActivityEvent[];
renderActivityEvent?: (event: ActivityEvent) => ReactNode;
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
canRecordWatchdogDecisions?: boolean;
watchdogDecisionError?: string | null;
onWatchdogDecision?: (input: WatchdogDecisionInput) => void;
};
type LedgerRun = RunForIssue & {
isLive?: boolean;
agentName?: string;
outputSilence?: ActiveRunForIssue["outputSilence"];
};
type LedgerFeedItem =
| {
kind: "run";
id: string;
timestamp: string;
run: LedgerRun;
}
| {
kind: "activity";
id: string;
timestamp: string;
event: ActivityEvent;
};
type LivenessCopy = {
label: string;
tone: string;
description: string;
};
const LIVENESS_COPY: Record<RunLivenessState, LivenessCopy> = {
completed: {
label: "Completed",
tone: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
description: "Issue reached a terminal state.",
},
advanced: {
label: "Advanced",
tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
description: "Run produced concrete evidence of progress.",
},
plan_only: {
label: "Plan only",
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
description: "Run described future work without concrete action evidence.",
},
empty_response: {
label: "Empty response",
tone: "border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300",
description: "Run finished without useful output.",
},
blocked: {
label: "Blocked",
tone: "border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300",
description: "Run or issue declared a blocker.",
},
failed: {
label: "Failed",
tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
description: "Run ended unsuccessfully.",
},
needs_followup: {
label: "Needs follow-up",
tone: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
description: "Run produced useful output but did not prove concrete progress.",
},
};
const PENDING_LIVENESS_COPY: LivenessCopy = {
label: "Checks after finish",
tone: "border-border bg-background text-muted-foreground",
description: "Liveness is evaluated after the run finishes.",
};
const RETRY_PENDING_LIVENESS_COPY: LivenessCopy = {
label: "Retry pending",
tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
description: "Paperclip queued an automatic retry that has not started yet.",
};
const MISSING_LIVENESS_COPY: LivenessCopy = {
label: "No liveness data",
tone: "border-border bg-background text-muted-foreground",
description: "This run has no persisted liveness classification.",
};
const TERMINAL_CHILD_STATUSES = new Set<Issue["status"]>(["done", "cancelled"]);
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
type RunOutputSilenceLevel = NonNullable<ActiveRunForIssue["outputSilence"]>["level"];
type RunOutputSilenceCopy = {
label: string;
tone: string;
};
const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSilenceCopy>> = {
suspicious: {
label: "Silence watch",
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
critical: {
label: "Stale run",
tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
},
snoozed: {
label: "Silence snoozed",
tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
},
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
interface ModelProfileSummary {
requested: string;
applied: string | null;
configSource: string | null;
fallbackReason: string | null;
}
function modelProfileForRun(run: RunForIssue): ModelProfileSummary | null {
const result = asRecord(run.resultJson);
const profile = asRecord(result?.modelProfile);
if (!profile) return null;
const requested = readString(profile.requested);
if (!requested) return null;
return {
requested,
applied: readString(profile.applied),
configSource: readString(profile.configSource),
fallbackReason: readString(profile.fallbackReason),
};
}
function modelProfileBadgeTone(summary: ModelProfileSummary) {
if (summary.applied === summary.requested) {
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
}
if (summary.fallbackReason) {
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
}
return "border-border bg-background text-muted-foreground";
}
function modelProfileTitle(summary: ModelProfileSummary) {
const lines = [`Requested: ${summary.requested}`];
if (summary.applied) lines.push(`Applied: ${summary.applied}`);
if (summary.configSource) lines.push(`Source: ${summary.configSource}`);
if (summary.fallbackReason) lines.push(`Fallback: ${summary.fallbackReason}`);
return lines.join("\n");
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined) {
if (!start) return null;
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
const totalSeconds = Math.max(0, Math.round((endMs - startMs) / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
function toIsoString(value: string | Date | null | undefined) {
if (!value) return null;
return value instanceof Date ? value.toISOString() : value;
}
function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun {
return {
runId: run.id,
status: run.status,
agentId: run.agentId,
agentName: run.agentName,
adapterType: run.adapterType,
startedAt: toIsoString(run.startedAt),
finishedAt: toIsoString(run.finishedAt),
createdAt: toIsoString(run.createdAt) ?? new Date().toISOString(),
invocationSource: run.invocationSource,
usageJson: null,
resultJson: null,
isLive: run.status === "queued" || run.status === "running",
outputSilence: run.outputSilence,
};
}
function mergeRuns(
runs: RunForIssue[],
liveRuns: LiveRunForIssue[] | undefined,
activeRun: ActiveRunForIssue | null | undefined,
) {
const byId = new Map<string, LedgerRun>();
for (const run of runs) byId.set(run.runId, run);
for (const run of liveRuns ?? []) {
const existing = byId.get(run.id);
byId.set(
run.id,
existing
? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence }
: liveRunToLedgerRun(run),
);
}
if (activeRun) {
const existing = byId.get(activeRun.id);
if (existing) {
byId.set(activeRun.id, {
...existing,
isLive: isActiveRun(existing) || isActiveRun(activeRun),
agentName: activeRun.agentName,
outputSilence: activeRun.outputSilence,
});
} else {
byId.set(activeRun.id, liveRunToLedgerRun(activeRun));
}
}
return [...byId.values()].sort((a, b) => {
const aTime = new Date(a.startedAt ?? a.createdAt).getTime();
const bTime = new Date(b.startedAt ?? b.createdAt).getTime();
if (aTime !== bTime) return bTime - aTime;
return b.runId.localeCompare(a.runId);
});
}
function statusLabel(status: string) {
return status.replace(/_/g, " ");
}
function isActiveRun(run: Pick<LedgerRun, "status" | "isLive">) {
return run.isLive || ACTIVE_RUN_STATUSES.has(run.status);
}
function runSummary(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
const agentName = compactAgentName(run, agentMap);
if (run.status === "running") return `Running now by ${agentName}`;
if (run.status === "queued") return `Queued for ${agentName}`;
if (run.status === "scheduled_retry") return `Automatic retry scheduled for ${agentName}`;
return `${statusLabel(run.status)} by ${agentName}`;
}
function livenessCopyForRun(run: LedgerRun) {
if (run.status === "scheduled_retry") return RETRY_PENDING_LIVENESS_COPY;
if (run.livenessState) return LIVENESS_COPY[run.livenessState];
return isActiveRun(run) ? PENDING_LIVENESS_COPY : MISSING_LIVENESS_COPY;
}
function stopReasonLabel(run: RunForIssue) {
const result = asRecord(run.resultJson);
const stopReason = readString(result?.stopReason);
const timeoutFired = result?.timeoutFired === true;
const effectiveTimeoutSec = readNumber(result?.effectiveTimeoutSec);
const timeoutText =
effectiveTimeoutSec && effectiveTimeoutSec > 0 ? `${effectiveTimeoutSec}s timeout` : null;
if (timeoutFired || stopReason === "timeout") {
return timeoutText ? `timeout (${timeoutText})` : "timeout";
}
if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted";
if (stopReason === "budget_paused") return "budget paused";
if (stopReason === "cancelled") return "cancelled";
if (stopReason === "paused") return "paused by board";
if (stopReason === "process_lost") return "process lost";
if (stopReason === "adapter_failed") return "adapter failed";
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
return timeoutText;
}
function stopStatusLabel(run: LedgerRun, stopReason: string | null) {
if (stopReason) return stopReason;
if (run.status === "scheduled_retry") return "Retry pending";
if (run.status === "queued") return "Waiting to start";
if (run.status === "running") return "Still running";
if (!run.livenessState) return "Unavailable";
return "No stop reason";
}
function lastUsefulActionLabel(run: LedgerRun) {
if (run.status === "scheduled_retry") return "Waiting for next attempt";
if (run.lastUsefulActionAt) return relativeTime(run.lastUsefulActionAt);
if (isActiveRun(run)) return "No action recorded yet";
if (run.livenessState === "plan_only" || run.livenessState === "needs_followup") {
return "No concrete action";
}
if (run.livenessState === "empty_response") return "No useful output";
if (!run.livenessState) return "Unavailable";
return "None recorded";
}
function continuationLabel(run: LedgerRun) {
if (!run.continuationAttempt || run.continuationAttempt <= 0) return null;
return `Continuation attempt ${run.continuationAttempt}`;
}
function hasExhaustedContinuation(run: RunForIssue) {
return /continuation attempts exhausted/i.test(run.livenessReason ?? "");
}
function childIssueSummary(childIssues: Issue[]) {
const active = childIssues.filter((issue) => !TERMINAL_CHILD_STATUSES.has(issue.status));
const done = childIssues.filter((issue) => issue.status === "done").length;
const cancelled = childIssues.filter((issue) => issue.status === "cancelled").length;
return { active, done, cancelled, total: childIssues.length };
}
function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
}
function formatSilenceAge(ms: number | null | undefined) {
if (!ms || ms <= 0) return null;
const totalMinutes = Math.floor(ms / 60_000);
if (totalMinutes < 1) return "under 1 minute";
if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`;
return `${hours}h ${minutes}m`;
}
function canBoardRecordWatchdogDecision(
companyId: string,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
const membership = boardAccess.memberships?.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
function watchdogDecisionErrorMessage(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
return "Only the board or the assigned recovery owner can record watchdog decisions";
}
return error instanceof Error && error.message.trim().length > 0
? error.message
: "Paperclip could not record the watchdog decision.";
}
export function IssueRunLedger({
issueId,
companyId,
issueStatus,
childIssues,
agentMap,
hasLiveRuns,
activityEvents,
renderActivityEvent,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [watchdogDecisionError, setWatchdogDecisionError] = useState<string | null>(null);
const { data: boardAccess } = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
retry: false,
});
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 5000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: hasLiveRuns,
refetchInterval: 3000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
});
const { data: activeRun = null } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: hasLiveRuns || issueStatus === "in_progress",
refetchInterval: hasLiveRuns ? false : 3000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
});
const watchdogDecision = useMutation({
mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input),
onMutate: () => {
setWatchdogDecisionError(null);
},
onSuccess: () => {
setWatchdogDecisionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
},
onError: (error) => {
const message = watchdogDecisionErrorMessage(error);
const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error";
setWatchdogDecisionError(message);
pushToast({
title: "Watchdog decision not recorded",
body: message,
tone: "error",
dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`,
});
},
});
return (
<IssueRunLedgerContent
runs={runs ?? []}
liveRuns={liveRuns}
activeRun={activeRun}
issueStatus={issueStatus}
childIssues={childIssues}
agentMap={agentMap}
activityEvents={activityEvents}
renderActivityEvent={renderActivityEvent}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
watchdogDecisionError={watchdogDecisionError}
onWatchdogDecision={(input) => watchdogDecision.mutate(input)}
/>
);
}
export function IssueRunLedgerContent({
runs,
liveRuns,
activeRun,
issueStatus,
childIssues,
agentMap,
activityEvents,
renderActivityEvent,
pendingWatchdogDecision,
canRecordWatchdogDecisions = true,
watchdogDecisionError,
onWatchdogDecision,
}: IssueRunLedgerContentProps) {
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
const latestRun = ledgerRuns[0] ?? null;
const latestSilentRun = useMemo(
() =>
ledgerRuns.find((run) =>
isActiveRun(run)
&& (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"),
) ?? null,
[ledgerRuns],
);
const children = childIssueSummary(childIssues);
const canRenderActivityEvents = Boolean(renderActivityEvent);
const feedItems = useMemo<LedgerFeedItem[]>(() => {
const items: LedgerFeedItem[] = [];
for (const run of ledgerRuns) {
items.push({
kind: "run",
id: run.runId,
timestamp: run.startedAt ?? run.createdAt,
run,
});
}
if (canRenderActivityEvents) {
for (const event of activityEvents ?? []) {
items.push({
kind: "activity",
id: event.id,
timestamp: event.createdAt instanceof Date
? event.createdAt.toISOString()
: String(event.createdAt),
event,
});
}
}
return items.sort((a, b) => {
const aTime = new Date(a.timestamp).getTime();
const bTime = new Date(b.timestamp).getTime();
if (aTime !== bTime) return bTime - aTime;
if (a.kind !== b.kind) return a.kind === "run" ? -1 : 1;
return b.id.localeCompare(a.id);
});
}, [activityEvents, canRenderActivityEvents, ledgerRuns]);
return (
<section className="space-y-3" aria-label="Issue run ledger">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-sm font-medium text-muted-foreground">Run ledger</h3>
<p className="text-xs text-muted-foreground">
{latestRun
? runSummary(latestRun, agentMap)
: issueStatus === "in_progress"
? "Waiting for the first run record."
: "No runs linked yet."}
</p>
</div>
{latestRun ? (
<Link
to={`/agents/${latestRun.agentId}/runs/${latestRun.runId}`}
className="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Latest run
</Link>
) : null}
</div>
{children.total > 0 ? (
<div className="rounded-md border border-border/70 px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-medium text-foreground">Child work</span>
<span className="text-muted-foreground">
{children.active.length > 0
? `${children.active.length} active, ${children.done} done, ${children.cancelled} cancelled`
: `all ${children.total} terminal (${children.done} done, ${children.cancelled} cancelled)`}
</span>
</div>
{children.active.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{children.active.slice(0, 4).map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent/40"
>
<span className="shrink-0 font-mono text-muted-foreground">{child.identifier ?? child.id.slice(0, 8)}</span>
<span className="truncate">{child.title}</span>
<span className="shrink-0 text-muted-foreground">{statusLabel(child.status)}</span>
</Link>
))}
{children.active.length > 4 ? (
<span className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground">
+{children.active.length - 4} more
</span>
) : null}
</div>
) : null}
</div>
) : null}
{latestSilentRun?.outputSilence ? (
<div
className={cn(
"rounded-md border px-3 py-2 text-xs",
latestSilentRun.outputSilence.level === "critical"
? "border-red-500/30 bg-red-500/10 text-red-900 dark:text-red-200"
: "border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200",
)}
>
<p className="font-medium">
{latestSilentRun.outputSilence.level === "critical"
? "Stale-run watchdog alert"
: "Output silence watchdog warning"}
</p>
<p className="mt-1">
Latest active run has been silent for{" "}
{formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}.
{latestSilentRun.outputSilence.evaluationIssueIdentifier ? (
<>
{" "}
Review{" "}
<Link
to={`/issues/${latestSilentRun.outputSilence.evaluationIssueIdentifier}`}
className="font-medium underline underline-offset-2"
>
{latestSilentRun.outputSilence.evaluationIssueIdentifier}
</Link>
{" "}for recovery context.
</>
) : null}
</p>
{onWatchdogDecision && canRecordWatchdogDecisions ? (
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "continue",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
})}
disabled={pendingWatchdogDecision != null}
>
Continue monitoring
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "snooze",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
snoozedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
reason: "Snoozed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Snooze 1h
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "dismissed_false_positive",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
reason: "Dismissed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Mark false positive
</button>
</div>
) : null}
{watchdogDecisionError ? (
<p className="mt-2 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-[11px] text-red-900 dark:text-red-200">
{watchdogDecisionError}
</p>
) : null}
</div>
) : null}
{feedItems.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
{renderActivityEvent
? "Runs and activity will appear here once this issue has history."
: "Historical runs without liveness metadata will appear here once linked to this issue."}
</div>
) : (
<div className="space-y-1.5">
{feedItems.slice(0, 20).map((item) => {
if (item.kind === "activity") {
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
}
const run = item.run;
const liveness = livenessCopyForRun(run);
const stopReason = stopReasonLabel(run);
const duration = formatDuration(run.startedAt, run.finishedAt);
const exhausted = hasExhaustedContinuation(run);
const continuation = continuationLabel(run);
const retryState = describeRunRetryState(run);
const agentName = compactAgentName(run, agentMap);
return (
<article
key={`run:${run.runId}`}
className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-foreground">Run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="min-w-0 max-w-full truncate font-mono text-foreground hover:underline"
>
{run.runId.slice(0, 8)}
</Link>
<span>by {agentName}</span>
<span className="rounded-md border border-border px-1.5 py-0.5 text-[11px] capitalize text-muted-foreground">
{statusLabel(run.status)}
</span>
{run.isLive ? (
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-1.5 py-0.5 text-[11px] text-cyan-700 dark:text-cyan-300">
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400" />
live
</span>
) : null}
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
liveness.tone,
)}
title={liveness.description}
>
{liveness.label}
</span>
{exhausted ? (
<span className="rounded-md border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[11px] font-medium text-red-700 dark:text-red-300">
Exhausted
</span>
) : null}
{continuation ? (
<span className="text-[11px] text-muted-foreground">{continuation}</span>
) : null}
{retryState ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
retryState.tone,
)}
>
{retryState.badgeLabel}
</span>
) : null}
{run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.tone,
)}
>
{RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label}
</span>
) : null}
{(() => {
const profile = modelProfileForRun(run);
if (!profile) return null;
const label = profile.applied === profile.requested
? `Profile: ${profile.requested}`
: profile.applied
? `Profile: ${profile.requested}${profile.applied}`
: `Profile: ${profile.requested} (unavailable)`;
return (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
modelProfileBadgeTone(profile),
)}
title={modelProfileTitle(profile)}
>
{label}
</span>
);
})()}
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<div className="min-w-0">
<span className="text-foreground">Elapsed</span>{" "}
{duration ?? "unknown"}
</div>
<div className="min-w-0">
<span className="text-foreground">Last useful action</span>{" "}
{lastUsefulActionLabel(run)}
</div>
<div className="min-w-0">
<span className="text-foreground">Stop</span>{" "}
{stopStatusLabel(run, stopReason)}
</div>
</div>
{retryState ? (
<div className="rounded-md border border-border/70 bg-accent/20 px-2 py-2 text-xs leading-5 text-muted-foreground">
{retryState.detail ? <p>{retryState.detail}</p> : null}
{retryState.secondary ? <p>{retryState.secondary}</p> : null}
{retryState.retryOfRunId ? (
<p>
Retry of{" "}
<Link
to={`/agents/${run.agentId}/runs/${retryState.retryOfRunId}`}
className="font-mono text-foreground hover:underline"
>
{retryState.retryOfRunId.slice(0, 8)}
</Link>
</p>
) : null}
</div>
) : null}
{(() => {
const profile = modelProfileForRun(run);
if (!profile?.fallbackReason || profile.applied === profile.requested) return null;
return (
<p className="min-w-0 break-words text-[11px] leading-5 text-amber-700 dark:text-amber-300">
{profile.requested === "cheap"
? "Cheap profile fell back to primary"
: `${profile.requested} profile unavailable`}
{": "}
<span className="font-mono">{profile.fallbackReason}</span>
</p>
);
})()}
{run.livenessReason ? (
<p className="min-w-0 break-words text-xs leading-5 text-muted-foreground">
{run.livenessReason}
</p>
) : null}
{run.nextAction ? (
<div className="min-w-0 rounded-md bg-accent/40 px-2 py-1.5 text-xs leading-5">
<span className="font-medium text-foreground">Next action: </span>
<span className="break-words text-muted-foreground">{run.nextAction}</span>
</div>
) : null}
</article>
);
})}
{feedItems.length > 20 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
{feedItems.length - 20} older items not shown
</div>
) : null}
</div>
)}
</section>
);
}