From d9d4e2368bc496bdad0049b3c2c88bd393b796a4 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 7 Sep 2026 13:16:14 +0800 Subject: [PATCH] feat(web): one cross-session "Needs you" queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5.3 (docs/conductor-frontends-design.md §8). Every blocking event across every agent in one ranked list, with the count always visible in the status bar and the queue one click away. The problem it removes: today you find the agent that stopped by opening sessions until you hit it. Three unrelated things block — a session wedged on a tool approval, a provider dialog waiting on an answer, a dispatch task that hit the anti-spin limit — and none of them announces itself anywhere shared. Collection and ranking are pure (`lib/attention.ts`); `state/attention.ts` only joins the three sources; the component is the surface. **Ranked by §8's (blocking-cost × staleness).** A `question` or `approval` has stopped a LIVE agent mid-turn, so it outranks a `blocked` task, which is stopped but burning nothing. `failed` ranks lowest — it is the one kind that can still resolve without you, since it retries with backoff. Staleness then overtakes kind as things sit, which is what makes "an agent stuck 8 minutes on a yes/no floats to the top" true. An age FLOOR is what makes that work at both ends. Without it every fresh item scores zero regardless of kind, so the queue would rank purely by age exactly when a live wedged agent most needs to be first. Three correctness details worth naming: - **One stoppage is one row.** A session showing a provider dialog is usually ALSO `waiting_approval`; counting both would tell you two things need you when one does, in a queue whose entire job is that count. - **An unknown age reads as brand new, never ancient**, so something with no usable timestamp cannot jump the queue. Future timestamps clamp to zero age too — clock skew between daemon and browser is normal and must not bury a real interruption. - **Ties break totally and stably** (since, then key). A queue that reshuffles equal rows between renders is one you cannot click. Timestamps are approximate and the code says so: neither a ui_request nor a FleetTaskWire carries the moment it began waiting, so a session falls back to `lastActivityAt` (a wedged session stops producing activity the instant it wedges) and a task to its creation time. Both err toward looking OLDER than reality, which biases toward surfacing rather than hiding — the right direction here. The badge shows zero rather than disappearing: a badge that vanishes cannot be trusted as an at-a-glance "nothing is stuck" signal, because you cannot tell clear from broken. Verified against a real board, not just fixtures: the two tasks blocked by the old daemon-collision bug outrank a 3-minute-old wedged agent — staleness overtaking kind weight — and the finished tasks stay out. 14 attention tests; 243 web tests; typecheck, lint and build clean. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/AttentionInbox.tsx | 136 ++++++++++++++++ web/src/components/StatusBar.tsx | 3 + web/src/lib/attention.test.ts | 225 ++++++++++++++++++++++++++ web/src/lib/attention.ts | 176 ++++++++++++++++++++ web/src/state/attention.ts | 38 +++++ web/src/state/ui-requests.ts | 9 ++ 6 files changed, 587 insertions(+) create mode 100644 web/src/components/AttentionInbox.tsx create mode 100644 web/src/lib/attention.test.ts create mode 100644 web/src/lib/attention.ts create mode 100644 web/src/state/attention.ts diff --git a/web/src/components/AttentionInbox.tsx b/web/src/components/AttentionInbox.tsx new file mode 100644 index 0000000..126fdef --- /dev/null +++ b/web/src/components/AttentionInbox.tsx @@ -0,0 +1,136 @@ +/** + * The "Needs you" inbox — one cross-session queue of everything blocking + * (conductor-frontends-design §8). + * + * Ambient count in the status bar, opening a ranked list. §8 asks for the + * count to be always visible and the queue one click away: the point is that + * you stop polling N sessions to find the one that stopped. + * + * Ranking and collection are pure (`lib/attention.ts`); this is the surface. + */ + +import { Component, For, Show, createSignal, onCleanup } from "solid-js"; + +import { relativeTime } from "../lib/format"; +import type { AttentionItem, AttentionKind } from "../lib/attention"; +import { attentionCount, attentionItems } from "../state/attention"; +import { nowTick } from "../state/clock"; +import { focusSession } from "../state/sessions"; + +const KIND_STYLE: Record = { + question: { cls: "border-warn/60 bg-warn/15 text-warn", label: "asks" }, + approval: { cls: "border-warn/60 bg-warn/15 text-warn", label: "approve" }, + blocked: { cls: "border-danger/60 bg-danger/15 text-danger", label: "blocked" }, + failed: { cls: "border-danger/40 bg-danger/10 text-danger", label: "failed" }, +}; + +const AttentionInbox: Component = () => { + const [open, setOpen] = createSignal(false); + + // Close on Escape — the panel is an overlay, and a keyboard user must be + // able to dismiss it without hunting for the toggle. + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + if (typeof window !== "undefined") { + window.addEventListener("keydown", onKey); + onCleanup(() => window.removeEventListener("keydown", onKey)); + } + + return ( +
+ + + + {/* Click-away backdrop, behind the panel. */} +
setOpen(false)} aria-hidden="true" /> +
+ 0} + fallback={ +

+ Nothing is waiting on you. Agents that wedge on an approval, ask a + question, or hit the failure limit show up here. +

+ } + > +
    + + {(item) => setOpen(false)} />} + +
+
+
+ +
+ ); +}; + +const Row: Component<{ item: AttentionItem; onGo: () => void }> = (props) => { + const style = () => KIND_STYLE[props.item.kind]; + const body = ( + <> +
+ + {style().label} + + + {props.item.label} + + + {relativeTime(new Date(props.item.since).toISOString(), nowTick())} + +
+

+ {props.item.detail} +

+ + ); + + return ( +
  • + {/* A stopped task whose worker was torn down has nowhere to go, so it + renders as a plain row rather than a button that does nothing. */} + {body}
  • } + > + {(id) => ( + + )} + + + ); +}; + +export default AttentionInbox; diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx index 990b768..7f7f539 100644 --- a/web/src/components/StatusBar.tsx +++ b/web/src/components/StatusBar.tsx @@ -10,6 +10,7 @@ import { Component, Show } from "solid-js"; +import AttentionInbox from "./AttentionInbox"; import HomeToggle from "./HomeToggle"; import { @@ -54,6 +55,8 @@ const StatusBar: Component = () => { + {/* Ambient — §8 wants the count always visible, the queue one click away. */} + diff --git a/web/src/lib/attention.test.ts b/web/src/lib/attention.test.ts new file mode 100644 index 0000000..9a950ca --- /dev/null +++ b/web/src/lib/attention.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; + +import { + attentionQueue, + attentionScore, + collectAttention, + rankAttention, + type AttentionItem, + type AttentionSources, +} from "./attention"; +import type { FleetTaskWire, SessionInfo, SessionUiRequestMsg } from "../protocol/types"; + +const NOW = 1_000_000_000_000; +const MIN = 60_000; + +const session = (id: string, over: Partial = {}): SessionInfo => + ({ id, name: id, status: "idle", ...over }) as SessionInfo; + +const uiReq = (sessionId: string, requestId: string, title: string): SessionUiRequestMsg => + ({ type: "session.ui_request", sessionId, requestId, method: "confirm", title }) as SessionUiRequestMsg; + +const task = (id: string, over: Partial = {}): FleetTaskWire => ({ + id, + kind: "spawn", + shape: "scout", + status: "blocked", + attempts: 2, + createdAt: NOW, + createdBy: "agent:conductor", + ...over, +}); + +const sources = (over: Partial = {}): AttentionSources => ({ + sessions: [], + uiRequests: {}, + tasks: [], + taskSession: () => null, + ...over, +}); + +const iso = (ms: number) => new Date(ms).toISOString(); + +describe("collectAttention", () => { + it("gathers dialogs, wedged sessions and stopped tasks into one queue", () => { + const items = collectAttention( + sources({ + sessions: [ + session("a", { status: "waiting_approval" }), + session("b"), + session("c", { status: "idle" }), + ], + uiRequests: { b: [uiReq("b", "r1", "Pick a branch")] }, + tasks: [task("t1"), task("t2", { status: "failed" }), task("t3", { status: "done" })], + }), + NOW, + ); + expect(items.map((i) => i.kind).sort()).toEqual([ + "approval", + "blocked", + "failed", + "question", + ]); + // A `done` task is not an interruption. + expect(items.some((i) => i.key.includes("t3"))).toBe(false); + }); + + it("does not double-count one stoppage as both a dialog and an approval", () => { + // A session showing a provider dialog is usually ALSO waiting_approval. + // Counting both would tell the operator two things need them when one does. + const items = collectAttention( + sources({ + sessions: [session("a", { status: "waiting_approval" })], + uiRequests: { a: [uiReq("a", "r1", "Confirm?")] }, + }), + NOW, + ); + expect(items).toHaveLength(1); + expect(items[0]!.kind).toBe("question"); + expect(items[0]!.detail).toBe("Confirm?"); + }); + + it("skips a dialog whose session this client cannot see", () => { + // Clicking it would go nowhere, so it is not actionable. + const items = collectAttention( + sources({ sessions: [], uiRequests: { ghost: [uiReq("ghost", "r1", "?")] } }), + NOW, + ); + expect(items).toEqual([]); + }); + + it("labels a task by its session when it has one, and by id when it does not", () => { + const worker = session("w1", { name: "worker-scout-abc" }); + const withSession = collectAttention( + sources({ + sessions: [worker], + tasks: [task("t1", { workerSessionId: "w1" })], + taskSession: () => worker, + }), + NOW, + ); + expect(withSession[0]!.label).toBe("worker-scout-abc"); + expect(withSession[0]!.sessionId).toBe("w1"); + + // A finished worker is torn down, so a stopped task often has no session. + const orphan = collectAttention(sources({ tasks: [task("abcdef12")] }), NOW); + expect(orphan[0]!.label).toBe("spawn abcdef12"); + expect(orphan[0]!.sessionId).toBeNull(); + }); + + it("carries the task's real error as the detail", () => { + const items = collectAttention( + sources({ tasks: [task("t1", { error: "reclaimed: stale claim" })] }), + NOW, + ); + expect(items[0]!.detail).toBe("reclaimed: stale claim"); + }); + + it("treats an unknown or unparseable age as brand new, never as ancient", () => { + // An unknown must not jump the queue. + const items = collectAttention( + sources({ + sessions: [ + session("a", { status: "waiting_approval" }), + session("b", { status: "waiting_approval", lastActivityAt: "not-a-date" }), + ], + }), + NOW, + ); + expect(items.every((i) => i.since === NOW)).toBe(true); + }); +}); + +describe("attentionScore / rankAttention", () => { + const item = (over: Partial): AttentionItem => ({ + key: "k", + kind: "approval", + sessionId: "s", + label: "s", + detail: "", + since: NOW, + ...over, + }); + + it("ranks a live wedged agent above a stopped task of the same age", () => { + const q = rankAttention( + [ + item({ key: "task", kind: "blocked" }), + item({ key: "agent", kind: "approval" }), + ], + NOW, + ); + expect(q.map((i) => i.key)).toEqual(["agent", "task"]); + }); + + it("floats a long-stuck yes/no above a fresher one — §8's worked example", () => { + const q = rankAttention( + [ + item({ key: "fresh", kind: "question", since: NOW }), + item({ key: "stuck8m", kind: "question", since: NOW - 8 * MIN }), + ], + NOW, + ); + expect(q[0]!.key).toBe("stuck8m"); + }); + + it("lets kind dominate while everything is new", () => { + // Without the age floor every fresh item scores zero and the queue would + // rank purely by age exactly when a wedged agent most needs to be first. + const q = rankAttention( + [ + item({ key: "failed", kind: "failed", since: NOW }), + item({ key: "question", kind: "question", since: NOW }), + ], + NOW, + ); + expect(q[0]!.key).toBe("question"); + }); + + it("lets staleness eventually overtake kind", () => { + const q = rankAttention( + [ + item({ key: "freshQuestion", kind: "question", since: NOW }), + item({ key: "oldFailure", kind: "failed", since: NOW - 30 * MIN }), + ], + NOW, + ); + expect(q[0]!.key).toBe("oldFailure"); + }); + + it("orders ties totally and stably", () => { + // A queue that reshuffles equal rows between renders is one you cannot + // click accurately. + const a = item({ key: "aaa" }); + const b = item({ key: "bbb" }); + expect(rankAttention([b, a], NOW).map((i) => i.key)).toEqual(["aaa", "bbb"]); + expect(rankAttention([a, b], NOW).map((i) => i.key)).toEqual(["aaa", "bbb"]); + }); + + it("scores a future timestamp as brand new rather than negative", () => { + // Clock skew between daemon and browser is normal; it must not produce a + // negative score that buries a real interruption. + const future = item({ since: NOW + 5 * MIN }); + expect(attentionScore(future, NOW)).toBeGreaterThan(0); + expect(attentionScore(future, NOW)).toBe(attentionScore(item({ since: NOW }), NOW)); + }); +}); + +describe("attentionQueue", () => { + it("collects and ranks in one call", () => { + const q = attentionQueue( + sources({ + sessions: [session("wedged", { status: "waiting_approval", lastActivityAt: iso(NOW) })], + tasks: [task("old", { createdAt: NOW - 60 * MIN })], + }), + NOW, + ); + expect(q).toHaveLength(2); + // The hour-old blocked task outranks the just-wedged agent. + expect(q[0]!.key).toBe("task:old"); + }); + + it("returns an empty queue when nothing needs you", () => { + expect(attentionQueue(sources({ sessions: [session("a")] }), NOW)).toEqual([]); + }); +}); diff --git a/web/src/lib/attention.ts b/web/src/lib/attention.ts new file mode 100644 index 0000000..a78c3ae --- /dev/null +++ b/web/src/lib/attention.ts @@ -0,0 +1,176 @@ +/** + * The cross-session attention queue — one ranked "Needs you" list + * (conductor-frontends-design §8). + * + * Every blocking event across every agent, in one place: a session wedged on a + * tool approval, a provider dialog waiting on an answer, a dispatch task that + * hit the anti-spin limit. Without this the operator polls N sessions to find + * the one that stopped; with it, the work comes to them. + * + * Pure functions — collection and ranking are the decisions worth testing, and + * they need no reactive root. + */ + +import type { FleetTaskWire, SessionInfo, SessionUiRequestMsg } from "../protocol/types"; + +export type AttentionKind = "question" | "approval" | "blocked" | "failed"; + +export interface AttentionItem { + /** Stable across re-collection, so a list keyed on it does not thrash. */ + key: string; + kind: AttentionKind; + /** The session to open. Null for a task whose session is gone or not yet spawned. */ + sessionId: string | null; + /** What to show as the row's subject — a session name, or a task id. */ + label: string; + detail: string; + /** Epoch ms this started blocking. See `sinceFor` on why it is approximate. */ + since: number; +} + +/** + * Blocking cost per kind — the multiplier in §8's (blocking-cost × staleness). + * + * A `question` or `approval` has stopped a LIVE agent mid-turn: the work is + * paid for, in flight, and going nowhere until a human answers. A `blocked` + * task is stopped too but is not burning anything and will never self-clear. + * A `failed` task ranks lowest because it is the one kind that can still + * resolve without you — it retries with backoff (§6). + */ +const COST: Record = { + question: 3, + approval: 3, + blocked: 2, + failed: 1, +}; + +/** + * Floor added to every item's age before scoring, in ms. + * + * Without it a brand-new item scores zero regardless of kind, so the queue + * would rank purely by age for the first moments of anything's life — exactly + * when a live wedged agent most needs to be at the top. Thirty seconds means + * kind dominates early and staleness takes over as things sit, which is the + * behaviour §8 describes ("an agent stuck 8 minutes on a yes/no floats to the + * top"). + */ +const AGE_FLOOR_MS = 30_000; + +/** §8's (blocking-cost × staleness). Higher is more urgent. */ +export function attentionScore(item: AttentionItem, now: number): number { + const age = Math.max(0, now - item.since); + return COST[item.kind] * (age + AGE_FLOOR_MS); +} + +export interface AttentionSources { + sessions: readonly SessionInfo[]; + /** Pending provider dialogs, keyed by session id. */ + uiRequests: Readonly>; + /** Fleet board tasks; only the stopped ones contribute. */ + tasks: readonly FleetTaskWire[]; + /** Resolve a task to its session, when it still has one. */ + taskSession: (task: FleetTaskWire) => SessionInfo | null; +} + +/** + * When an item started blocking. + * + * Approximate, and worth being honest about: neither a `ui_request` nor a + * `FleetTaskWire` carries the moment it began waiting. A session's + * `lastActivityAt` is the closest true signal — a wedged session stops + * producing activity the instant it wedges — and a task falls back to its + * creation time. Both err toward looking OLDER than reality for a task that + * ran a while before stopping, which biases the queue toward surfacing things + * rather than hiding them. That is the right direction for this list. + */ +function sinceFor(iso: string | undefined, fallback: number): number { + if (!iso) return fallback; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : fallback; +} + +/** + * Gather every blocking item across the whole session population. + * + * `now` is the fallback timestamp for anything with no usable time, so an item + * with unknown age reads as brand new rather than as infinitely stale — an + * unknown must not jump the queue. + */ +export function collectAttention(src: AttentionSources, now: number): AttentionItem[] { + const items: AttentionItem[] = []; + const byId = new Map(src.sessions.map((s) => [s.id, s])); + + // Provider dialogs first: they name their own question, so they carry more + // information than the session status that accompanies them. + const asked = new Set(); + for (const [sessionId, reqs] of Object.entries(src.uiRequests)) { + for (const req of reqs) { + const session = byId.get(sessionId); + // A request for a session this client cannot see is not actionable — + // clicking it would go nowhere — so it is skipped rather than shown. + if (!session) continue; + asked.add(sessionId); + items.push({ + key: `ui:${sessionId}:${req.requestId}`, + kind: "question", + sessionId, + label: session.name, + detail: req.title, + since: sinceFor(session.lastActivityAt, now), + }); + } + } + + // Sessions wedged on a tool approval. Skipped when a dialog is already + // listed for that session: they are the same stoppage, and showing both + // would double-count one interruption in a queue whose whole job is to say + // how many things need you. + for (const s of src.sessions) { + if (s.status !== "waiting_approval" || asked.has(s.id)) continue; + items.push({ + key: `approval:${s.id}`, + kind: "approval", + sessionId: s.id, + label: s.name, + detail: "waiting for tool approval", + since: sinceFor(s.lastActivityAt, now), + }); + } + + // Stopped dispatch tasks. + for (const task of src.tasks) { + if (task.status !== "blocked" && task.status !== "failed") continue; + const session = src.taskSession(task); + items.push({ + key: `task:${task.id}`, + kind: task.status, + sessionId: session?.id ?? null, + label: session?.name ?? `${task.kind} ${task.id.slice(0, 8)}`, + detail: task.error ?? (task.status === "blocked" ? "hit the failure limit" : "task failed"), + since: task.createdAt, + }); + } + + return items; +} + +/** + * Rank most-urgent first. + * + * Ties break on `since` (oldest first) and then on `key`, so the order is + * total and stable: a queue that reshuffles equal-priority rows between + * renders is one you cannot click accurately. + */ +export function rankAttention(items: readonly AttentionItem[], now: number): AttentionItem[] { + return [...items].sort((a, b) => { + const d = attentionScore(b, now) - attentionScore(a, now); + if (d !== 0) return d; + if (a.since !== b.since) return a.since - b.since; + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; + }); +} + +/** Everything in one call — collect, then rank. */ +export function attentionQueue(src: AttentionSources, now: number): AttentionItem[] { + return rankAttention(collectAttention(src, now), now); +} diff --git a/web/src/state/attention.ts b/web/src/state/attention.ts new file mode 100644 index 0000000..989e11c --- /dev/null +++ b/web/src/state/attention.ts @@ -0,0 +1,38 @@ +/** + * The cross-session attention queue, wired to live state (§8). + * + * Thin by design: `lib/attention.ts` owns collection and ranking, and this + * only joins the three sources — the session population, pending provider + * dialogs, and the fleet board — and re-ranks as the clock moves. + */ + +import { createMemo } from "solid-js"; + +import { attentionQueue, type AttentionItem } from "../lib/attention"; +import { nowTick } from "./clock"; +import { fleetBoard, taskSession } from "./fleet"; +import { sessionList } from "./sessions"; +import { allPendingUiRequests } from "./ui-requests"; + +/** + * Ranked "needs you" items, most urgent first. + * + * Depends on `nowTick` because the ranking is (blocking-cost × staleness) — + * an item that nobody touches still climbs as it ages, so the order has to be + * recomputed on the shared clock rather than only when the data changes. + */ +export const attentionItems = createMemo(() => { + const board = fleetBoard(); + return attentionQueue( + { + sessions: sessionList(), + uiRequests: allPendingUiRequests(), + tasks: board.tasks, + taskSession: (t) => taskSession(board, t), + }, + nowTick(), + ); +}); + +/** How many things are waiting on you — for the ambient badge. */ +export const attentionCount = createMemo(() => attentionItems().length); diff --git a/web/src/state/ui-requests.ts b/web/src/state/ui-requests.ts index 77aeea9..6e75cbb 100644 --- a/web/src/state/ui-requests.ts +++ b/web/src/state/ui-requests.ts @@ -50,6 +50,15 @@ export function pendingUiRequest(sessionId: string | null): SessionUiRequestMsg return state.bySession[sessionId]?.[0] ?? null; } +/** + * Every pending dialog, keyed by session — the cross-session attention queue + * (lib/attention.ts) needs the whole population, not one session's slice. + * Reading the store directly keeps it reactive for callers inside a memo. + */ +export function allPendingUiRequests(): Readonly> { + return state.bySession; +} + /** Count of pending dialogs for a session (badge / tests). */ export function pendingUiRequestCount(sessionId: string): number { return state.bySession[sessionId]?.length ?? 0;