diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index bf972d0251..53a5f47a08 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -66,6 +66,7 @@ async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) { let lastError: unknown = null; for (let attempt = 0; attempt < 10; attempt += 1) { await db.delete(activityLog); + await db.delete(heartbeatRunEvents); try { await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 2bf0ba8443..f052ee41a3 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -223,6 +223,118 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).toBe(""); }); + it("keeps the amber notice when a covered chain has no confirmed live blocker", () => { + const node = render( + , + ); + + expect(node.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull(); + expect(node.textContent).toContain("Work on this task is blocked by the linked task"); + expect(node.querySelector('[data-blocker-attention-state="covered"]')).not.toBeNull(); + }); + + it("sorts same-status live-work steps with numeric identifier ordering", () => { + const node = render( + , + ); + + const stepLinks = Array.from( + node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a'), + ).map((link) => link.textContent ?? ""); + + expect(stepLinks[0]).toContain("TASK-9"); + expect(stepLinks[1]).toContain("TASK-10"); + expect(stepLinks[2]).toContain("TASK-11"); + }); + it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => { const node = render( = new Set(); + +type WaitingStepStatus = "done" | "running" | "queued"; + +function classifyWaitingStep( + blocker: IssueRelationIssueSummary, + liveIds: ReadonlySet, +): WaitingStepStatus { + // A resolved blocker (done/cancelled) is a completed step; a blocker with a + // live run is the one currently being worked; everything else is queued. + if (blocker.status === "done" || blocker.status === "cancelled") return "done"; + if (liveIds.has(blocker.id)) return "running"; + return "queued"; +} + +// Ordering heuristic (plan §3): done → running → queued, tie-break by identifier +// (P1…Pn plan naming). The payload doesn't carry explicit chain order. +const WAITING_STEP_RANK: Record = { + done: 0, + running: 1, + queued: 2, +}; + +function WaitingChipLink({ + blocker, + running = false, +}: { + blocker: IssueRelationIssueSummary; + running?: boolean; +}) { + const issuePathId = blocker.identifier ?? blocker.id; + return ( + + {blocker.identifier ?? blocker.id.slice(0, 8)} + + {blocker.title} + + {running ? ( + + running + + ) : null} + + ); +} + +function WaitingStepGlyph({ status }: { status: WaitingStepStatus }) { + if (status === "done") { + return ; + } + if (status === "running") { + return ( + + + + ); + } + return ; +} + +/** + * 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; + parkedBlockers: IssueRelationIssueSummary[]; + renderParkedChip: (blocker: IssueRelationIssueSummary) => ReactNode; +}) { + const steps = chainBlockers + .map((blocker) => ({ blocker, status: classifyWaitingStep(blocker, liveIds) })) + .sort((a, b) => { + const rank = WAITING_STEP_RANK[a.status] - WAITING_STEP_RANK[b.status]; + if (rank !== 0) return rank; + const aKey = a.blocker.identifier ?? a.blocker.id; + const bKey = b.blocker.identifier ?? b.blocker.id; + return aKey.localeCompare(bKey, undefined, { numeric: true }); + }); + 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; otherwise fall back to whichever chain blocker is live. + const nowRunningSeen = new Set(); + const nowRunning: IssueRelationIssueSummary[] = []; + for (const blocker of [...terminalBlockers, ...chainBlockers]) { + if (!liveIds.has(blocker.id)) continue; + if (nowRunningSeen.has(blocker.id)) continue; + nowRunningSeen.add(blocker.id); + nowRunning.push(blocker); + } + + const queuedNoun = total === 1 ? "task" : "tasks"; + + return ( +
+
+ + + +
+
+

Waiting on live work

+

+ Queued behind {total} {queuedNoun} being worked in order. This task + resumes automatically when the chain is done. Comments still wake the + responsible agent. +

+
+ +
+
+ {doneCount} of {total} done + {runningCount > 0 ? ` · ${runningCount} running` : null} +
+
+ {steps.map(({ blocker, status }) => ( + + ))} +
+
+ +
+ {steps.map(({ blocker, status }) => ( +
+
+ + + + +
+
+ {status === "running" ? ( +
+ +
+ ) : ( + + )} +
+
+ ))} +
+
+ +
+
+ + This task — resumes automatically when the chain is done + +
+
+
+ + {nowRunning.length > 0 ? ( +
+ + Now running + + {nowRunning.map((blocker) => ( + + ))} +
+ ) : null} + + {parkedBlockers.length > 0 ? ( +
+ + + Blocked by parked work + + {parkedBlockers.map((blocker) => renderParkedChip(blocker))} +
+ ) : null} +
+
+
+ ); +} + export function IssueBlockedNotice({ issueId, issueStatus, blockers, + allBlockers, + liveIssueIds, blockerAttention, successfulRunHandoff, scheduledRetry, @@ -119,7 +355,16 @@ export function IssueBlockedNotice({ }: { 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; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; scheduledRetry?: IssueScheduledRetry | null; @@ -198,6 +443,35 @@ export function IssueBlockedNotice({ ); }; + // 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 ( + + ); + } + return (
{ }); }); + it("renders the blue 'Waiting on live work' variant when the blocker chain is covered", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const notice = container.querySelector('[data-testid="issue-blocked-notice-live"]'); + expect(notice).not.toBeNull(); + expect(notice?.getAttribute("data-blocker-attention-state")).toBe("covered"); + expect(container.textContent).toContain("Waiting on live work"); + expect(container.textContent).toContain("resumes automatically when the chain is done"); + // Progress counts: 1 done, 1 running out of 3. + expect(container.textContent).toContain("1 of 3 done · 1 running"); + // Amber "Ultimately waiting on" / "blocked by the linked task" copy is gone. + expect(container.textContent).not.toContain("Ultimately waiting on"); + expect(container.textContent).not.toContain("Work on this task is blocked by"); + // All three blockers render as chips. + expect(container.querySelector('[data-issue-path-id="PAP-2001"]')).not.toBeNull(); + expect(container.querySelector('[data-issue-path-id="PAP-2002"]')).not.toBeNull(); + expect(container.querySelector('[data-issue-path-id="PAP-2003"]')).not.toBeNull(); + + flushAct(() => { + root.unmount(); + }); + }); + + it("shows a 'Now running' row replacing 'Ultimately waiting on' when the terminal leaf is live", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const nowRunning = container.querySelector('[data-testid="issue-blocked-notice-now-running"]'); + expect(nowRunning).not.toBeNull(); + expect(nowRunning?.textContent).toContain("Now running"); + expect(nowRunning?.textContent).toContain("PAP-3002"); + expect(container.textContent).not.toContain("Ultimately waiting on"); + + flushAct(() => { + root.unmount(); + }); + }); + + it("keeps the parked-work row amber inside the blue variant", () => { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.querySelector('[data-testid="issue-blocked-notice-live"]')).not.toBeNull(); + const parkedRow = container.querySelector('[data-testid="issue-blocked-notice-parked-row"]'); + expect(parkedRow).not.toBeNull(); + expect(parkedRow?.textContent).toContain("Blocked by parked work"); + // Parked label keeps its amber tone even inside the blue box. + expect(parkedRow?.querySelector(".text-amber-800")).not.toBeNull(); + + flushAct(() => { + root.unmount(); + }); + }); + + it("keeps the amber notice byte-for-byte for stalled / needs_attention / none states", () => { + for (const state of ["stalled", "needs_attention", "none"] as const) { + const root = createRoot(container); + + flushAct(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + // Blue variant never appears for non-covered states. + expect(container.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull(); + // The amber container (with the canonical attention data attribute) does. + const amber = container.querySelector(`[data-blocker-attention-state="${state}"]`); + expect(amber).not.toBeNull(); + expect(amber?.className).toContain("border-amber-300/70"); + if (state === "stalled") { + expect(container.textContent).toContain("Stalled in review"); + } + + flushAct(() => { + root.unmount(); + }); + } + }); + it("shows paused responsible agent context above the composer", () => { const root = createRoot(container); const pausedAgent = { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 04f2199df3..11b5f0e4f9 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -422,6 +422,8 @@ interface IssueChatThreadProps { activeRun?: ActiveRunForIssue | null; issueId?: string | null; blockedBy?: IssueRelationIssueSummary[]; + /** Company-wide set of issue ids with a live (queued/running) run. */ + liveIssueIds?: ReadonlySet; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; scheduledRetry?: IssueScheduledRetry | null; @@ -4164,6 +4166,7 @@ export function IssueChatThread({ activeRun = null, issueId = null, blockedBy = [], + liveIssueIds, blockerAttention = null, successfulRunHandoff = null, scheduledRetry = null, @@ -4931,6 +4934,8 @@ export function IssueChatThread({ issueId={issueId} issueStatus={issueStatus} blockers={unresolvedBlockers} + allBlockers={blockedBy} + liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} successfulRunHandoff={recoveryAction ? null : successfulRunHandoff} scheduledRetry={scheduledRetry} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b4a078a71d..9c53f284f1 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -887,6 +887,7 @@ type IssueDetailChatTabProps = { issueWorkMode: IssueWorkMode; executionRunId: string | null; blockedBy: Issue["blockedBy"]; + liveIssueIds: ReadonlySet; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; scheduledRetry: Issue["scheduledRetry"] | null; @@ -970,6 +971,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueStatus, executionRunId, blockedBy, + liveIssueIds, blockerAttention, successfulRunHandoff, scheduledRetry, @@ -1193,6 +1195,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ activeRun={resolvedActiveRun} issueId={issueId} blockedBy={blockedBy ?? []} + liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} scheduledRetry={scheduledRetry} @@ -4664,6 +4667,7 @@ export function IssueDetail() { issueWorkMode={issue.workMode ?? "standard"} executionRunId={issue.executionRunId ?? null} blockedBy={issue.blockedBy ?? []} + liveIssueIds={liveIssueIds} blockerAttention={issue.blockerAttention ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null} scheduledRetry={issue.scheduledRetry ?? null}