- )}
-
- );
+ const footer: AgentStreamFooterItem[] | undefined = stats
+ ? [
+ { label: "re-checked", value: stats.examined },
+ { label: "checks", value: stats.toolCalls },
+ ...(stats.resurrected > 0 ? [{ label: "brought back", value: stats.resurrected, tone: "warn" as const }] : []),
+ ...(stats.shaken > 0 ? [{ label: "weakened", value: stats.shaken, tone: "warn" as const }] : []),
+ { label: "took", value: `${(stats.durationMs / 1000).toFixed(1)}s` },
+ ]
+ : undefined;
+ return ;
}
diff --git a/src/web/components/InvestigationPane.tsx b/src/web/components/InvestigationPane.tsx
index 2e099865..8701acc3 100644
--- a/src/web/components/InvestigationPane.tsx
+++ b/src/web/components/InvestigationPane.tsx
@@ -8,17 +8,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { ArrowLeft, FilePlus, RotateCw, ChevronDown, Download, Link2, FileText, Image as ImageIcon, ClipboardCopy, Check, Telescope } from "lucide-react";
+import { ArrowLeft, FilePlus, RotateCw, ChevronDown, Download, Link2, FileText, Image as ImageIcon, ClipboardCopy, Check, Telescope, Compass } from "lucide-react";
import { PhaseStepper, type PhaseState } from "./PhaseStepper";
import { EvidenceTimeline } from "./EvidenceTimeline";
import { RcaReport } from "./RcaReport";
import { DeepModeStream } from "./DeepModeStream";
+import { OrchestratorStream, type OrchestratorPause, type OrchestratorDisposition } from "./OrchestratorStream";
import { InvestigationFeedback } from "./InvestigationFeedback";
import { useStackContext } from "../contexts/StackContext";
import { useUnreadInvestigations } from "../hooks/useUnreadInvestigations";
import type { TimelineEvent } from "./ActivityTimeline";
import type { TimeSeriesData } from "./MetricChart";
-import type { ServerMessage, AgentStreamEvent, AgentStreamStats } from "../../types/ws-types.js";
+import type { ServerMessage, AgentStreamEvent, AgentStreamStats, OrchestratorStreamStats, CausalChainLink } from "../../types/ws-types.js";
import type { RcaReport as RcaReportType } from "../../types/rca-types.js";
import { formatTokens } from "../lib/formatTokens.js";
import { buildPhaseActions } from "../lib/grafana-links.js";
@@ -140,6 +141,8 @@ export function InvestigationPane({
onNavigateSkills,
onRerun,
onDeepMode,
+ onOrchestrate,
+ onOrchestratorDecision,
onWrongStack,
}: {
investigationId: string;
@@ -150,6 +153,13 @@ export function InvestigationPane({
/** Trigger deep mode (Step 3): skeptical re-examination of the loop's
* ruled-out causes. Parent wires it to the deep_mode_investigate WS message. */
onDeepMode?: (investigationId: string) => void;
+ /** Trigger the autonomous orchestrator (Approach D): the unbounded read-only
+ * move-loop that investigates for the real cause. Parent wires it to the
+ * orchestrator_investigate WS message. */
+ onOrchestrate?: (investigationId: string) => void;
+ /** Send the operator's strike-limit decision (increment 5) back over the WS.
+ * Parent wires it to the orchestrator_decision message. */
+ onOrchestratorDecision?: (investigationId: string, decision: "continue" | "escalate" | "wait") => void;
/** Called when the investigation 404s in the active stack but the locate
* endpoint reports it lives in a different stack. The parent should
* switchStack + navigate to the correct stack-scoped URL — keeps
@@ -166,6 +176,15 @@ export function InvestigationPane({
const [deepModeError, setDeepModeError] = useState(null);
const [deepSteps, setDeepSteps] = useState([]);
const [deepStats, setDeepStats] = useState(undefined);
+ const [orchRunning, setOrchRunning] = useState(false);
+ const [orchError, setOrchError] = useState(null);
+ const [orchSteps, setOrchSteps] = useState([]);
+ const [orchStats, setOrchStats] = useState(undefined);
+ const [orchOutcome, setOrchOutcome] = useState(undefined);
+ const [orchChain, setOrchChain] = useState(undefined);
+ const [orchTraceSummary, setOrchTraceSummary] = useState(undefined);
+ const [orchPause, setOrchPause] = useState(null);
+ const [orchDisposition, setOrchDisposition] = useState(undefined);
const [service, setService] = useState("");
const [query, setQuery] = useState("");
/** Set when the REST fetch comes back 404. Visiting an investigation URL
@@ -491,6 +510,39 @@ export function InvestigationPane({
setDeepModeRunning(false);
if (typeof msg.message === "string") setDeepModeError(msg.message);
}
+ // Autonomous orchestrator (Approach D) — the unbounded move-loop.
+ if (msg.type === "orchestrator:started" && msg.investigationId === investigationId) {
+ setOrchRunning(true);
+ setOrchError(null);
+ setOrchSteps([]);
+ setOrchStats(undefined);
+ setOrchOutcome(undefined);
+ setOrchChain(undefined);
+ setOrchTraceSummary(undefined);
+ setOrchPause(null);
+ setOrchDisposition(undefined);
+ }
+ if (msg.type === "orchestrator:step" && msg.investigationId === investigationId) {
+ setOrchSteps((prev) => [...prev, msg.event]);
+ // A new move means the loop resumed past any pause — clear the card.
+ setOrchPause(null);
+ }
+ if (msg.type === "orchestrator:operator_pause" && msg.investigationId === investigationId) {
+ setOrchPause({ strikes: msg.strikes, hypothesesTried: msg.hypothesesTried });
+ }
+ if (msg.type === "orchestrator:complete" && msg.investigationId === investigationId) {
+ setOrchRunning(false);
+ setOrchPause(null);
+ setOrchStats(msg.stats);
+ setOrchOutcome(msg.outcome);
+ setOrchChain(msg.causalChain);
+ setOrchTraceSummary(msg.traceSummary);
+ }
+ if (msg.type === "orchestrator:error" && msg.investigationId === investigationId) {
+ setOrchRunning(false);
+ setOrchPause(null);
+ if (typeof msg.message === "string") setOrchError(msg.message);
+ }
}
}, [wsMessages, investigationId]);
@@ -660,11 +712,33 @@ export function InvestigationPane({
);
})()}
+ {/* Autonomous orchestrator (Approach D): hidden until validated. Gated
+ behind config.agent.orchestratorEnabled (server injects
+ window.__ORCHESTRATOR_ENABLED__). Unlike deep mode it investigates
+ for the real cause, so it doesn't require a prior loop outcome. */}
+ {isComplete && onOrchestrate && (() => {
+ if (typeof window !== "undefined" && !window.__ORCHESTRATOR_ENABLED__) return null;
+ return (
+
+ );
+ })()}
{deepModeError && (
{deepModeError}
)}
+ {orchError && (
+
{orchError}
+ )}
{/* Progress bar — visible while running */}
{isRunning && (
@@ -760,6 +834,21 @@ export function InvestigationPane({
{/* Deep mode (Step 3) — dedicated structured agent stream (live + final). */}
+ {
+ if (decision === "escalate" || decision === "wait") setOrchDisposition(decision);
+ setOrchPause(null);
+ onOrchestratorDecision?.(investigationId, decision);
+ }}
+ />
{investigationStatus === "failed" && !report ? (
diff --git a/src/web/components/OrchestratorStream.tsx b/src/web/components/OrchestratorStream.tsx
new file mode 100644
index 00000000..ae23dde0
--- /dev/null
+++ b/src/web/components/OrchestratorStream.tsx
@@ -0,0 +1,161 @@
+import { AgentStream, type AgentStreamFooterItem, type AgentStreamBanner } from "./AgentStream.js";
+import type { AgentStreamEvent, OrchestratorStreamStats, CausalChainLink } from "../../types/ws-types.js";
+
+/**
+ * Autonomous orchestrator (Approach D) stream — the same AgentStream rendering
+ * as deep mode, with the orchestrator's footer (moves / queries / subagents /
+ * depth / strikes / tokens / elapsed), a terminal outcome banner, the causal
+ * chain (with source attribution), a one-line trace summary, and the
+ * interactive operator-pause card (continue / escalate / instrument-&-wait)
+ * shown when the loop hits its strike limit and is awaiting a human call.
+ */
+
+/** Operator's pending decision at a strike-limit pause (increment 5). */
+export type OrchestratorPause = { strikes: number; hypothesesTried?: string[] };
+/** The disposition the operator chose at a pause, once stopped (escalate/wait). */
+export type OrchestratorDisposition = "escalate" | "wait";
+
+function outcomeBanner(outcome: string | undefined, disposition?: OrchestratorDisposition): AgentStreamBanner | undefined {
+ // An explicit operator decision overrides the generic pause copy so the
+ // banner reflects what the human actually chose. Neither escalate nor wait
+ // has a backend in v1 (no on-call page / scheduler) — they record intent.
+ if (outcome === "operator-pause" && disposition === "escalate") {
+ return { text: "Escalated to on-call. (Recorded only — no paging integration yet.)", tone: "warn" };
+ }
+ if (outcome === "operator-pause" && disposition === "wait") {
+ return { text: "Marked to instrument & revisit. (Recorded only — no scheduler yet.)", tone: "muted" };
+ }
+ switch (outcome) {
+ case "confirmed":
+ return { text: "Confirmed a root cause — see the conclusion above.", tone: "good" };
+ case "operator-pause":
+ return {
+ text: "Paused — ruled out every hypothesis it tried without finding the cause. The signal is ambiguous; this one needs a human call.",
+ tone: "warn",
+ };
+ case "budget-exhausted":
+ return { text: "Stopped — hit the token budget before confirming a cause.", tone: "warn" };
+ case "tool-cap":
+ return { text: "Stopped — hit the query limit before confirming a cause.", tone: "warn" };
+ case "wall-clock":
+ return { text: "Stopped — hit the time limit before confirming a cause.", tone: "warn" };
+ case "exhausted":
+ return { text: "Stopped — the agent ran out of moves without confirming a cause.", tone: "muted" };
+ case "inconclusive":
+ return { text: "Stopped — no further progress; inconclusive.", tone: "muted" };
+ case "aborted":
+ return { text: "Stopped — the run was cancelled.", tone: "muted" };
+ default:
+ return undefined;
+ }
+}
+
+/** The strike-limit pause card: three explicit dispositions, no silent guess. */
+function OperatorPauseCard({ pause, onDecision }: { pause: OrchestratorPause; onDecision: (d: "continue" | OrchestratorDisposition) => void }) {
+ return (
+
+ The signal is ambiguous: every candidate cause tested was ruled out, and no discriminating evidence emerged.
+ Rather than guess, the orchestrator stops and asks you.
+
+ {/* Vertical cause→effect stack: each link on its own row with a subtle
+ connector, evidence indented beneath. Reads cleanly even when every
+ link carries a (multi-word) attribution line — unlike inline arrows,
+ which go ragged once the evidence sublines wrap. */}
+