diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index bcfec9a7f7..3a6e1f4648 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -2817,3 +2817,18 @@ now starts a targeted Patrol run, so alert follow-up lands on the Patrol page. `patrolInvestigationContextModel.ts` shrank to the investigation-record presentation that `InvestigationSection` uses, and the Patrol tool transcript block moved to `frontend-modern/src/components/patrol/ToolExecutionBlock.tsx`. + +### Failed run with a planned fix (23 October 2026) + +An investigation can persist a canonical action plan and then fail, normally +on the final summary call. The orchestrator (in pulse-pro) keeps the plan's +outcome, normally `fix_queued`, on the failed run instead of `timed_out` or +`interrupted`, so the retry gates leave the finding alone rather than plan the +same fix again, and it refuses a manual re-investigation while the latest +run's outcome is `fix_queued` (409 `action_pending`). The Investigation section +matches: it offers no Re-investigate while the outcome is `fix_queued`, +whatever the run status, and the failed run's note renders as information, as +for an interrupted run, because the queued fix is what the operator acts on. +Once core reconciles the plan's approval, rejection or expiry, the outcome +changes and the usual rules apply again. Proof: +`frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx`. diff --git a/frontend-modern/src/components/patrol/InvestigationSection.tsx b/frontend-modern/src/components/patrol/InvestigationSection.tsx index 4c3ae711c7..c132a4e2c7 100644 --- a/frontend-modern/src/components/patrol/InvestigationSection.tsx +++ b/frontend-modern/src/components/patrol/InvestigationSection.tsx @@ -44,10 +44,11 @@ const INVESTIGATION_BADGE_PROPS = { const summaryClass = 'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none wrap-break-word prose-headings:my-2 prose-p:my-2 prose-pre:overflow-x-auto prose-code:break-all prose-code:before:content-none prose-code:after:content-none'; -// A run cut short by a restart did not fail: Patrol runs it again, so its note -// reads as information rather than as an error. +// A run cut short by a restart did not fail: Patrol runs it again. A run that +// stopped after planning a fix left that fix waiting for approval. Either way, +// its note reads as information rather than as an error. const runNoteClass = (outcome: string | undefined): string => - outcome === 'interrupted' + outcome === 'interrupted' || outcome === 'fix_queued' ? 'border-border-subtle bg-surface text-muted' : 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/25 dark:text-red-300'; @@ -102,6 +103,9 @@ export const InvestigationSection: Component = (props const inv = investigation(); if (!inv) return true; // No investigation yet if (inv.status === 'running') return false; + // A queued fix is still open, even after a run that failed later. + // Investigating again would plan a second action for the same problem. + if (inv.outcome === 'fix_queued') return false; return ( inv.status === 'failed' || inv.status === 'needs_attention' || diff --git a/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx index 29928d257b..1284332cf5 100644 --- a/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx @@ -163,6 +163,29 @@ describe('InvestigationSection', () => { expect(screen.getByRole('button', { name: 'Re-investigate' })).toBeInTheDocument(); }); + it('does not offer another run while a planned fix is queued', async () => { + getInvestigationMock.mockResolvedValue({ + id: 'inv-queued', + finding_id: 'finding-queued', + session_id: 'session-queued', + status: 'failed', + outcome: 'fix_queued', + started_at: '2026-10-23T10:00:00.000Z', + turn_count: 6, + error: 'The provider stopped responding.', + } satisfies Investigation); + + render(() => ); + + // The run failed after planning a fix; the fix is still waiting for approval. + await screen.findByText(getInvestigationOutcomeLabel('fix_queued')); + expect(screen.queryByRole('button', { name: 'Re-investigate' })).not.toBeInTheDocument(); + // The run's note is information beside the queued fix, not an alarm. + const noteBox = screen.getByText('The provider stopped responding.'); + expect(noteBox.className).toContain('text-muted'); + expect(noteBox.className).not.toContain('bg-red-50'); + }); + it('keeps a failed run error in the error style', async () => { getInvestigationMock.mockResolvedValue({ id: 'inv-failed',