/** * `AnnouncementsProvider` — the live, project-scoped store for inter-agent announcements * (ticket #4, F1 wiring). Subscribes **once** to the domain-event stream via the * {@link SystemGateway} and folds two orthogonal signals: * * 1. **Announcement content** — `agentAnnouncement` events appended to the * bounded `(target, ticketId)` index (the scrolling text of the overlay F3 * and the requester preview F2). * 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single * authority for mounting/retracting the target overlay (Architect arbitrage * 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated * from the reconciled read-model `ProjectWorkState.agents[].busy` at mount / * reboot (via {@link useHydrateAgentBusy}). * 3. **Self-restart presentation** — the live restart phase plus a small, * deduplicated journal used by the native-TUI overlay and custom-CLI * separators. It is deliberately presentation-only and never persisted to * the human conversation transcript. * * Why busy — not "there are announcements" — drives F3: a turn can end **without** * any completion event (interruption / error / crash / rate-limit). The mediator's * `agentBusyChanged` `busy:false` falls at idle in *every* case, and the read-model * is reconciled on reboot, so the overlay can never stick. A rate-limited agent * stays `busy:true`, so its overlay persists with no special-casing. * * (On this `develop` base there is no canonical `agentTurnEvent`/`final` signal; * busy is the sole lifecycle authority, which is exactly what we want.) * * Consumers read via {@link useTargetAnnouncements} (F3) and * {@link useRequesterAnnouncements} (F2). The store is never persisted and is * reset whenever the provider's project changes; it is rebuilt live from the * event stream and the read-model snapshot for that project. */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useGateways } from "@/app/di"; import type { AgentRestartPhase, DomainEvent } from "@/domain"; import { announcementsForRequester, announcementsForTarget, appendAnnouncement, emptyAnnouncementIndex, purgeAllForTarget, type Announcement, type AnnouncementIndex, } from "./announcementsStore"; /** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */ type BusyMap = Record; export type AgentRestartJournalEvent = Extract< DomainEvent, { type: | "agentRestartRequested" | "agentRestartCompleted" | "agentRestartFailed"; } >; export interface AgentRestartPresentation { restartId: string; phase: AgentRestartPhase | "requested"; outcome: "running" | "completed" | "failed"; message?: string; } type RestartMap = Record; type RestartJournal = Record; interface AnnouncementsStore { /** Bounded announcement content, indexed by `(target, ticketId)`. */ index: AnnouncementIndex; /** Per-agent busy state — the overlay's lifecycle authority. */ busy: BusyMap; restarts: RestartMap; restartJournal: RestartJournal; /** * Seeds an agent's busy state from the read-model, but only when no live signal * is yet known for it: live `agentBusyChanged` events always win over a * (possibly staler) hydration snapshot. */ seedBusy: (agentId: string, busy: boolean) => void; } const noopSeed = () => {}; const EMPTY_INDEX = emptyAnnouncementIndex(); const EMPTY_BUSY: BusyMap = {}; const EMPTY_RESTARTS: RestartMap = {}; const EMPTY_RESTART_JOURNAL: RestartJournal = {}; /** Store shape read outside a provider — a silent, inert default (never throws). */ const DEFAULT_STORE: AnnouncementsStore = { index: EMPTY_INDEX, busy: EMPTY_BUSY, restarts: EMPTY_RESTARTS, restartJournal: EMPTY_RESTART_JOURNAL, seedBusy: noopSeed, }; const AnnouncementsContext = createContext(null); export function AnnouncementsProvider({ projectId, children, }: { projectId: string | null; children: ReactNode; }) { const { system } = useGateways(); const [index, setIndex] = useState(emptyAnnouncementIndex); const [busy, setBusy] = useState({}); const busyRef = useRef({}); const [restarts, setRestarts] = useState({}); const [restartJournal, setRestartJournal] = useState({}); // Hydration seed: apply only when the agent is unknown, so a live event that // already landed (or lands before the async read-model resolves) is not clobbered. const seedBusy = useCallback((agentId: string, value: boolean) => { if (agentId in busyRef.current) return; busyRef.current = { ...busyRef.current, [agentId]: value }; setBusy((prev) => ({ ...prev, [agentId]: value })); }, []); useEffect(() => { setIndex(emptyAnnouncementIndex()); setBusy({}); busyRef.current = {}; setRestarts({}); setRestartJournal({}); }, [projectId]); useEffect(() => { // `system` may be absent in unit tests injecting a partial gateway set. if (!system || !projectId) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (event.type === "agentAnnouncement") { if (event.projectId !== projectId) return; // Content of the overlay/preview. setIndex((prev) => appendAnnouncement(prev, { requester: event.requester, target: event.target, ticketId: event.ticketId, text: event.text, atMs: event.atMs, }), ); } else if (event.type === "agentBusyChanged") { // Lifecycle authority. `true` mounts the target overlay; `false` retracts // it (idle) and clears the target's content so a next turn starts fresh — // even when no completion event was ever emitted (the sticking case). const { agentId, busy: isBusy } = event; busyRef.current = { ...busyRef.current, [agentId]: isBusy }; setBusy((prev) => ({ ...prev, [agentId]: isBusy })); if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId)); if (!isBusy) { setRestarts((prev) => { const restart = prev[agentId]; if (!restart || restart.outcome !== "completed") return prev; const next = { ...prev }; delete next[agentId]; return next; }); } } else if (event.type === "agentRestartRequested") { setRestarts((prev) => ({ ...prev, [event.agentId]: { restartId: event.restartId, phase: "requested", outcome: "running", }, })); setRestartJournal((prev) => appendRestartJournal(prev, event)); } else if (event.type === "agentRestartPhaseChanged") { setRestarts((prev) => { const current = prev[event.agentId]; if (!current || current.restartId !== event.restartId) return prev; return { ...prev, [event.agentId]: { ...current, phase: event.phase }, }; }); } else if (event.type === "agentRestartCompleted") { setRestarts((prev) => { const current = prev[event.agentId]; if (!current || current.restartId !== event.restartId) return prev; if (busyRef.current[event.agentId] === false) { const next = { ...prev }; delete next[event.agentId]; return next; } return { ...prev, [event.agentId]: { ...current, phase: "resuming", outcome: "completed", }, }; }); setRestartJournal((prev) => appendRestartJournal(prev, event)); } else if (event.type === "agentRestartFailed") { setRestarts((prev) => { const current = prev[event.agentId]; if (!current || current.restartId !== event.restartId) return prev; return { ...prev, [event.agentId]: { ...current, phase: event.phase, outcome: "failed", message: event.message, }, }; }); setRestartJournal((prev) => appendRestartJournal(prev, event)); } }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }) .catch(() => { // Event relay unavailable in this environment — the store stays empty. }); return () => { cancelled = true; unsubscribe?.(); }; }, [system, projectId]); const store = useMemo( () => ({ index, busy, restarts, restartJournal, seedBusy }), [index, busy, restarts, restartJournal, seedBusy], ); return ( {children} ); } function appendRestartJournal( journal: RestartJournal, event: AgentRestartJournalEvent, ): RestartJournal { const key = `${event.restartId}:${event.type}`; const previous = journal[event.agentId] ?? []; if (previous.some((item) => `${item.restartId}:${item.type}` === key)) return journal; return { ...journal, [event.agentId]: [...previous, event].slice(-12), }; } /** The store, or an inert default when read outside a provider. */ function useStore(): AnnouncementsStore { return useContext(AnnouncementsContext) ?? DEFAULT_STORE; } /** Whether a real provider is mounted above (hydration only runs when it is). */ function useWithinProvider(): boolean { return useContext(AnnouncementsContext) !== null; } /** * Announcements destined to `target` (F3): the flattened, time-ordered content * plus `active` — whether the overlay should be mounted. `active` is the target's * **busy** state (the lifecycle authority), NOT the mere presence of announcements. */ export function useTargetAnnouncements(target: string): { announcements: Announcement[]; active: boolean; } { const { index, busy } = useStore(); return useMemo( () => ({ announcements: announcementsForTarget(index, target), active: busy[target] === true, }), [index, busy, target], ); } export function useAgentRestart(agentId: string): { restart: AgentRestartPresentation | null; events: AgentRestartJournalEvent[]; } { const { restarts, restartJournal } = useStore(); return useMemo( () => ({ restart: restarts[agentId] ?? null, events: restartJournal[agentId] ?? [], }), [agentId, restartJournal, restarts], ); } /** * Hydrates the target's busy state from the reconciled read-model at mount/reboot, * so an overlay mounts for a turn already in flight when its cell appears — no live * `agentBusyChanged` will replay for it. Live events subsequently win (see * `seedBusy`). Guarded: no-op outside a provider or without a `workState` gateway. */ export function useHydrateAgentBusy(projectId: string, agentId: string): void { const { workState } = useGateways(); const { seedBusy } = useStore(); const within = useWithinProvider(); useEffect(() => { if (!within || !workState) return; let cancelled = false; void workState .getProjectWorkState(projectId) .then((state) => { if (cancelled) return; const agent = state.agents.find((a) => a.agentId === agentId); if (agent) seedBusy(agentId, agent.busy.state === "busy"); }) .catch(() => { // Read-model unavailable — fall back to the live event stream only. }); return () => { cancelled = true; }; }, [within, workState, projectId, agentId, seedBusy]); } /** * Announcements the given `requester` is waiting on (F2), filtered by * `requester == self` so a shared target thread never leaks another requester's * announcements. Optionally scoped to a single `ticketId`. */ export function useRequesterAnnouncements( requester: string, ticketId?: string, ): Announcement[] { const { index } = useStore(); return useMemo( () => announcementsForRequester(index, requester, ticketId), [index, requester, ticketId], ); }