diff --git a/docs/agent-dashboard-retention-restart.md b/docs/agent-dashboard-retention-restart.md index df8bb92ccc9..715203e77ec 100644 --- a/docs/agent-dashboard-retention-restart.md +++ b/docs/agent-dashboard-retention-restart.md @@ -6,7 +6,7 @@ When a user has the experimental agent dashboard toggle on (`settings.experiment The user-visible problem is broader than "done rows vanish": any agent whose status was visible before quit and which does not re-emit a hook event on its own — `done` agents (process gone, no future event), `blocked` Claudes (idle at a permission prompt, no event until the user acts), and `working` agents that happen to be quiet at quit time — disappears from the dashboard until something happens to make them speak again. The most-cited symptom is `done` rows winking out, but the same restart-erasure hits `blocked` and quiet `working` for exactly the same reason: nothing replays the last-known status across the boundary. -Persisting the hook server's existing per-pane status cache fixes all three at once, with bounded renderer changes (a bootstrap queue in `useIpcEvents.ts` and IPC fan-outs in `dropAgentStatus` and `dismissRetainedAgentsByWorktree`) and no changes to the dashboard memos or `useRetainedAgentsSync`. +Persisting the hook server's existing per-pane status cache fixes all three at once. The renderer pulls a main-process snapshot after settings and workspace tabs are hydrated, so startup delivery no longer depends on early IPC timing; dismissals still flow back to main through `agentStatus:drop`. ## Scope @@ -15,11 +15,11 @@ In scope: - Persist `lastStatusByPaneKey` (the module-level `Map` in `src/main/agent-hooks/server.ts:65` that already caches the latest hook payload per `paneKey`) to a JSON file under `userData/agent-hooks/`. Hydrate it on `start()` before the HTTP server begins accepting requests. - Mirror cache mutations to disk through a debounced trailing write so a burst of hook events does not produce N file writes. Force a synchronous final flush on `stop()` so quit-time state is captured. - Update the on-disk file when `clearPaneState(paneKey)` is called from PTY teardown (`src/main/ipc/pty.ts:349`), so a pane that closes during a session does not resurrect on the next launch. -- Gate disk *writes* on `experimentalAgentDashboard === true` so users who have not opted in do not accumulate on-disk hook payloads. Skip hydration when the flag is off at launch. +- Gate disk _writes_ on `experimentalAgentDashboard === true` so users who have not opted in do not accumulate on-disk hook payloads. Skip hydration when the flag is off at launch. Out of scope: -- Renderer-side persistence (`retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, `retentionSuppressedPaneKeys`). The renderer's retention slice and `useRetainedAgentsSync` continue to operate purely in memory; their "stay until dismissed" contract is upheld by the live entry being replayed into the renderer through the existing `setListener` path on every window registration. +- Renderer-side persistence (`retainedAgentsByPaneKey`, `acknowledgedAgentsByPaneKey`, `retentionSuppressedPaneKeys`). The renderer's retention slice and `useRetainedAgentsSync` continue to operate purely in memory; their "stay until dismissed" contract is upheld by the live entry being pulled from the main-process status snapshot after renderer hydration. - Daemon-side persistence. The daemon owns PTY lifetime and scrollback, not hook events. The hook server (in the Electron main process) is the right home. - Cross-device sync. The on-disk file lives in `userData`; running Orca on a second machine does not carry retained activity. - Changing the experimental gate itself. The gate stays; this proposal makes the feature behave correctly when it is on. @@ -36,11 +36,11 @@ The Electron main process runs a loopback HTTP server (`AgentHookServer` in `src `src/main/index.ts:277-305` registers the listener on window creation. The listener forwards the payload to the renderer via `webContents.send('agentStatus:set', …)`, gated on `experimentalAgentDashboard === true` (index.ts:285). The renderer's `agentStatusByPaneKey` slice receives the IPC and writes the entry. From there, `useRetainedAgentsSync` produces `retainedAgentsByPaneKey` (sticky `done` snapshots), and the rest of the dashboard reads from those two maps. -The cache exists for a reason that already overlaps with this proposal: hook events arrive while Orca is windowless (common on macOS when the user closes the window but leaves the app running), so re-registering the listener replays everything cached so far. `setListener()` at server.ts:1103-1117 walks `lastStatusByPaneKey.values()` and re-fires the listener for each entry. **This is the seam this design rides on.** Disk hydration writes into the same map; the next window registration replays everything to the renderer through the path already in production. +The cache exists for a reason that already overlaps with this proposal: hook events arrive while Orca is windowless (common on macOS when the user closes the window but leaves the app running), so re-registering the listener replays everything cached so far. Disk hydration writes into the same map. The renderer now also has a pull-based `agentStatus:getSnapshot` path, which lets startup restore wait until renderer settings and workspace tabs are ready instead of relying on early replay timing. ### What survives restart today, indirectly -The persistent-terminal daemon survives Orca quit (it is spawned `detached`, see `src/main/daemon/daemon-init.ts:115-116`, and the IPC channel is disconnected, not killed, on quit). PTYs stay alive. The renderer reconnects to the daemon on launch and replays scrollback. Hook stdout from agents that *do something post-launch* repopulates `agentStatusByPaneKey` naturally — but the hook server is fresh because: +The persistent-terminal daemon survives Orca quit (it is spawned `detached`, see `src/main/daemon/daemon-init.ts:115-116`, and the IPC channel is disconnected, not killed, on quit). PTYs stay alive. The renderer reconnects to the daemon on launch and replays scrollback. Hook stdout from agents that _do something post-launch_ repopulates `agentStatusByPaneKey` naturally — but the hook server is fresh because: - `agentHookServer.stop()` runs at `src/main/index.ts:624` during `will-quit`, clearing `lastStatusByPaneKey` (server.ts:1248). - `agentHookServer.start()` runs at `src/main/index.ts:560` during the next launch with a brand-new `Map`. @@ -51,7 +51,7 @@ So on relaunch the cache is empty, and any pane whose agent does not emit a fres - `blocked` Claudes (idle waiting on permission prompt — no event until the user acts). - Quiet `working` agents (active but mid-step, not currently emitting). -Persisting the cache fixes all three. Live agents that *do* emit an event post-launch overwrite the hydrated entry through the same `lastStatusByPaneKey.set` path that updates them today (server.ts:1179) — no special collision handling needed. +Persisting the cache fixes all three. Live agents that _do_ emit an event post-launch overwrite the hydrated entry through the same `lastStatusByPaneKey.set` path that updates them today (server.ts:1179) — no special collision handling needed. ## Proposed Shape @@ -66,7 +66,7 @@ On-disk shape: // Why: a tiny version field is cheap insurance against future reshapes // (e.g. trimming oversized lastAssistantMessage, dropping stateHistory). // Mismatched versions are silently dropped on hydrate — no migration code. - "version": 1, + "version": 2, // Why: paneKey is `${tabId}:${paneId}`; mirrors the in-memory Map shape // exactly so hydrate is a single Object.entries → map.set loop. "entries": { @@ -74,19 +74,23 @@ On-disk shape: "paneKey": "tab-abc:pane-1", "tabId": "tab-abc", "worktreeId": "wt-…", - "payload": { /* ParsedAgentStatusPayload */ } + "receivedAt": 1710000000000, + "stateStartedAt": 1710000000000, + "payload": { + /* ParsedAgentStatusPayload */ + } } } } ``` -The shape is the structural mirror of the existing `AgentHookEventPayload` type (server.ts:44-49), wrapped in a `{ version, entries }` envelope. The payload is parsed on hydrate by the existing `parseAgentStatusPayload` / `normalizeAgentStatusPayload` from `src/shared/agent-status-types.ts:259, 268`, which already enforces the schema invariants the renderer assumes. +The shape is the structural mirror of the existing `AgentHookEventPayload` type (server.ts:44-49), wrapped in a `{ version, entries }` envelope. The timing fields preserve the real age of restored rows so relaunch does not make old statuses look fresh. The payload is parsed on hydrate by the existing `parseAgentStatusPayload` / `normalizeAgentStatusPayload` from `src/shared/agent-status-types.ts:259, 268`, which already enforces the schema invariants the renderer assumes. Why this file and not `PersistedUIState` (the prior renderer-side draft): - `PersistedUIState` holds scalar UI prefs (widths, sort orders, dismiss flags). The hook payload is structured event data — `AgentStatusEntry` carries `stateHistory[]`, `toolInput`, and `lastAssistantMessage`, any of which can be multi-KB. Bloating the prefs file with churning event data is a category mistake. - `userData/agent-hooks/` already exists as the home for hook-server on-disk coordination state (the endpoint file). Adding a cache file there keeps "hook-server-owned, survives-restart artifacts" co-located. -- The renderer is downstream of `lastStatusByPaneKey`; persisting upstream lets the renderer's retention slice and `useRetainedAgentsSync` operate unchanged off the live map. The only renderer change is a small bootstrap-window queue in `useIpcEvents.ts` so hydrated events that arrive before `workspaceSessionReady` are not dropped (see Hydration Rules). Persisting downstream would fork persistence across processes and only solve the `done` slice of the user complaint. +- The renderer is downstream of `lastStatusByPaneKey`; persisting upstream lets the renderer's retention slice and `useRetainedAgentsSync` operate unchanged off the live map. The renderer change is a small snapshot pull in `useIpcEvents.ts` after settings and `workspaceSessionReady` are true, plus timing propagation into `setAgentStatus`. Persisting downstream would fork persistence across processes and only solve the `done` slice of the user complaint. Why a `Record` (and not an array): matches the in-memory `Map` shape, so the read and write paths are one-line transforms. @@ -96,15 +100,15 @@ Why no field-level cap on payload size: the same payloads are already accepted i `lastStatusByPaneKey` (the in-memory map) is the source of truth. Persistence is a write-through mirror that hooks into the three existing mutation sites in `src/main/agent-hooks/server.ts`: -| Site | What changes | Persistence action | -|---|---|---| -| `lastStatusByPaneKey.set(...)` at HTTP handler (server.ts:1179) | New or updated payload for a pane | Schedule debounced write | -| `lastStatusByPaneKey.delete(...)` in `clearPaneState()` (server.ts:1262) | PTY for the pane has torn down | Schedule debounced write | -| `lastStatusByPaneKey.clear()` in `stop()` (server.ts:1248) | Server is shutting down | Schedule write, then synchronously flush before returning | +| Site | What changes | Persistence action | +| ------------------------------------------------------------------------ | --------------------------------- | --------------------------------------------------------- | +| `lastStatusByPaneKey.set(...)` at HTTP handler (server.ts:1179) | New or updated payload for a pane | Schedule debounced write | +| `lastStatusByPaneKey.delete(...)` in `clearPaneState()` (server.ts:1262) | PTY for the pane has torn down | Schedule debounced write | +| `lastStatusByPaneKey.clear()` in `stop()` (server.ts:1248) | Server is shutting down | Schedule write, then synchronously flush before returning | A single helper inside the class — `private scheduleStatusPersist()` — sets a 250 ms trailing timer, captured per-instance. Each scheduled write reads the current `lastStatusByPaneKey` and the current `experimentalAgentDashboard` setting, then: -- If the gate is on, writes `{ version: 1, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` at server.ts:1310). +- If the gate is on, writes `{ version: 2, entries: {…} }` to `last-status.json` (atomic via tmp + rename — same pattern as `writeEndpointFile()` at server.ts:1310). - If the gate is off, the write is a no-op for users who never opted in (the file does not exist; do not create one). For users who previously opted in, the write deletes the existing file once (one filesystem op, idempotent on subsequent attempts) and stops scheduling further writes until the gate flips back on. Deletion is preferred to "write empty" so a flag-off user never has hook-payload data on disk after the next sync. A `flushStatusPersistSync()` method runs the pending write synchronously. `stop()` calls it before clearing the map so quit-time state is captured even if a debounced write was pending. `will-quit` already has the synchronous order it needs (`agentHookServer.stop()` at index.ts:624 runs before `app.quit()` resolves), so the synchronous flush slots in cleanly. @@ -138,49 +142,51 @@ The `agentStatus:drop` channel is the only IPC surface change introduced by this ## Hydration Rules -Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (server.ts:1221) — so the in-memory map is fully populated before the first hook POST can arrive. The existing `setListener()` replay loop (server.ts:1110) then naturally walks every hydrated entry the first time the renderer registers a listener, which today happens at window creation (index.ts:277). +Hydration runs inside `start()` after the userDataPath is set up but before `server.listen()` is called (server.ts:1221) — so the in-memory map is fully populated before the first hook POST can arrive. The renderer reads that hydrated map through `agentStatus:getSnapshot` after settings and workspace tabs are ready. Order of operations on launch: 1. `agentHookServer.start({ userDataPath })` is called from `src/main/index.ts:560` before window creation. 2. Inside `start()`, before binding the HTTP server: read `userData/agent-hooks/last-status.json`, parse, sanitize, and `lastStatusByPaneKey.set(...)` each surviving entry. Failure modes (missing file, parse error, schema mismatch) all degrade gracefully — log once, leave the in-memory map empty. 3. The HTTP server binds and starts accepting POSTs. New hook events go through the same `lastStatusByPaneKey.set` write site (server.ts:1179), overwriting hydrated entries naturally. -4. Window creation registers the renderer listener via `setListener()` (index.ts:277), which replays every cached entry — hydrated or just-arrived — into the renderer through the existing `webContents.send('agentStatus:set', …)` IPC. +4. Window creation registers the renderer listener via `setListener()` (index.ts:277). Live pushes remain best-effort, but the startup restore no longer depends on these early sends being accepted by the renderer. +5. `useIpcEvents.ts` waits until `settings.experimentalAgentDashboard === true` and `workspaceSessionReady === true`, then invokes `window.api.agentStatus.getSnapshot()` and applies each surviving entry through `setAgentStatus(...)`. -Gate hydration on `experimentalAgentDashboard === true` at launch. When the flag is off, skip reading the file (the IPC forwarding gate at index.ts:285 already drops anything that arrives anyway, and we want to avoid loading event payloads into memory the user has not opted into). Do not delete the file in this case — toggling the flag back on later should not have to start clean. +Gate hydration on `experimentalAgentDashboard === true` at launch. When the flag is off, skip reading the file (the IPC forwarding gate at index.ts:285 already drops anything that arrives anyway, and we want to avoid loading event payloads into memory the user has not opted into). The next persistence pass deletes any existing file so a flag-off user does not keep hook payloads on disk. Sanitization is field-by-field rather than whole-entry rejection so a single bad pane cannot tank the whole hydration: -- Top-level: must be `{ version, entries }` with `version === 1` and `entries` an object. Anything else → empty hydration, single console warn. +- Top-level: must be `{ version, entries }` with `version === 2` and `entries` an object. Anything else → empty hydration, single console warn. - For each `[paneKey, entry]` pair: - `paneKey` must be a string matching `${tabId}:${paneId}` shape (non-empty `tabId` segment, non-empty `paneId` segment, exactly one `:`). Drop on mismatch. - `entry.paneKey` must equal the map key. Drop on mismatch — the key is the trusted form, but a divergence indicates corruption. - `entry.payload` must round-trip through `normalizeAgentStatusPayload` (`src/shared/agent-status-types.ts:259`) — this is the same validator the HTTP handler uses on every live POST, so the on-disk path inherits the same schema invariants without duplicating rules. - `entry.tabId` and `entry.worktreeId` must be either undefined or non-empty strings. Drop entries with empty-string values for these (they are typed optional but should never appear blank in practice). + - `entry.receivedAt` and `entry.stateStartedAt` must be positive finite numbers. Drop on mismatch so restored rows keep their real age instead of being restamped. -Note what we do *not* sanitize against: current worktrees, tab IDs, or daemon PTY liveness. Those checks belong downstream: +Note what we do _not_ sanitize against: current worktrees, tab IDs, or daemon PTY liveness. Those checks belong downstream: -- The renderer already prunes `retainedAgentsByPaneKey` against current worktrees in `useRetainedAgentsSync` (`pruneRetainedAgents(existingWorktreeIds)`). Hydrated live entries that map to dead worktrees will show up briefly in the renderer's live map, then disappear from the inline list when the next dashboard read filters by current worktrees. The same per-tab filter the dashboard already does for `agentStatusByPaneKey` will hide entries whose tab is not in `tabsByWorktree`. +- The renderer already prunes `retainedAgentsByPaneKey` against current worktrees in `useRetainedAgentsSync` (`pruneRetainedAgents(existingWorktreeIds)`). Snapshot entries that map to dead worktrees or unknown tabs are dropped by the same per-tab resolution path used for live pushes. - Daemon PTY liveness is not the hook server's knowledge. Adding a cross-process check here would couple the server to the daemon and slow down `start()`. The brief render of a "ghost" status that disappears on first sync is acceptable — strictly better than dropping live `blocked`/`working` entries because of a transient mismatch, and equivalent in practice for `done` entries (which are about something that already happened). Hydration timing relative to window creation: - The hook server starts before the window (`src/main/index.ts:560` vs. `src/main/index.ts:577`). Hydration is therefore complete before any renderer code can register a listener. -- `setListener()` is registered synchronously inside `openMainWindow()` (`src/main/index.ts:277`) — which fires the replay loop immediately, while the renderer bundle has not yet loaded. -- The renderer's IPC handler at `src/renderer/src/hooks/useIpcEvents.ts:816-849` drops payloads whose tab is not yet in `tabsByWorktree` (via `resolvePaneKey`), and the tab map is populated only after the async `App.tsx` init effect awaits `fetchRepos`/`fetchAllWorktrees`/`session.get()` and calls `hydrateTabsSession` (`src/renderer/src/App.tsx:217-237`). So even if Electron buffers the synchronous `webContents.send` calls until the renderer is ready, the in-renderer drop-on-unknown-tab guard would silently throw away every hydrated entry. +- `setListener()` is registered synchronously inside `openMainWindow()` (`src/main/index.ts:277`) and may replay before the renderer bundle has loaded or before `tabsByWorktree` is hydrated. +- The renderer treats those early pushes as disposable: if settings or workspace state is not ready, `useIpcEvents.ts` ignores the push. Once both are ready, it pulls the authoritative snapshot from main and applies entries whose paneKeys still resolve to live tabs. -To make hydration actually visible after launch, we add **one small renderer change** plus a deferred replay trigger: +To make hydration visible after launch, we add **one small renderer change** plus a deferred snapshot trigger: -1. **Renderer queue for unknown-tab events.** Modify `useIpcEvents.ts:816-849` so that when `resolvePaneKey(...).exists === false` AND `workspaceSessionReady === false`, the payload is pushed into a small in-memory queue (a module-level `Array<{paneKey, payload, title?}>`) instead of dropped. The drain trigger is a `useAppStore.subscribe` registered inside the existing `useIpcEvents` mount effect (so its lifetime matches the rest of `useIpcEvents`'s subscriptions and the cleanup unsubscribes it) — on attach it reads the current `workspaceSessionReady`; if already `true`, drains immediately, otherwise waits for the next `true` transition. The drain reprocesses each entry: re-resolve `paneKey`, drop any whose tab is still unknown (closed-in-prior-session orphan, silently hidden — same as the "Tab closed between sessions" edge case), `setAgentStatus(...)` the rest, then clear the queue and set a `drained = true` flag so subsequent unknown-tab events post-drain go through the existing drop path. The queue is bounded at 200 entries with a drop-newest-on-overflow policy (preserve the order of the early entries — they are the ones with the strongest claim on dashboard state). On every subscribe attach, reset the queue array to empty so HMR and Strict Mode double-mount cannot accumulate stale entries across remounts. +1. **Renderer snapshot pull.** Modify `useIpcEvents.ts` so live `agentStatus:set` pushes are applied only when the dashboard is enabled and `workspaceSessionReady` is true. Register a store subscription in the same effect; once those conditions are true, call `window.api.agentStatus.getSnapshot()`, re-resolve each paneKey, drop entries whose tab is still unknown, and call `setAgentStatus(...)` for the rest. -2. **No main-process changes** beyond what's already specified. `setListener()` keeps its current synchronous-replay semantics; the renderer is now resilient to early-arriving events. +2. **Snapshot timing.** Include `receivedAt` and `stateStartedAt` in both the persisted file and the IPC payload. `setAgentStatus` accepts those timestamps so restored rows retain their actual age, and it ignores an older snapshot entry if a newer live push already landed for the same pane. -This is the only renderer code change introduced by this design. Rationale for putting the queue in the renderer rather than deferring `setListener`: +This keeps `setListener()`'s synchronous replay semantics for windowless app sessions while making restart restore independent of renderer boot order. Rationale for using a snapshot instead of a renderer queue: - Deferring `setListener` until `webContents.did-finish-load` adds an `app/main` ↔ `renderer` handshake (a "renderer-ready" IPC ping) plus changes the lifetime guarantees of the existing windowless-replay path that the comment at `src/main/index.ts:260-264` already depends on. -- A renderer-side queue is contained, easy to reason about, and matches the existing `hydratePersistedUI` / `hydrateTabsSession` pattern of "renderer is the system that knows when its world is ready." +- A renderer-side queue still depends on early IPC delivery and requires a bounded buffer policy. The main process already owns the durable map, so a snapshot is simpler and cannot lose entries before the renderer knows settings or tabs. -The renderer slice's `setAgentStatus` is otherwise unchanged, and `useRetainedAgentsSync` continues to operate purely in memory off the live map. +The renderer slice's persistence model is otherwise unchanged, and `useRetainedAgentsSync` continues to operate purely in memory off the live map. ## Edge Cases @@ -194,7 +200,7 @@ The renderer slice's `setAgentStatus` is otherwise unchanged, and `useRetainedAg **On-disk corruption.** Sanitize field-by-field; never throw. A corrupt file yields an empty hydration result with a single console warn. The dashboard renders normally — the new behavior matches today's "no persistence at all" baseline, so any corruption mode is strictly no-worse than current state. -**Stale file from a prior Orca version with a different schema.** The `version: 1` envelope rejects mismatches at parse time. An older Orca that wrote `version: 0` (or no version field) is treated like a corrupt file → empty hydration. No migration code, no explosion. +**Stale file from a prior Orca version with a different schema.** The `version: 2` envelope rejects mismatches at parse time. An older Orca that wrote `version: 1` (or no version field) is treated like a corrupt file → empty hydration. No migration code, no explosion. **Dual instances of Orca pointing at the same `userData`.** Two processes race on `last-status.json`. Last write wins. The existing endpoint file at the same path has the same property (server.ts:1232-1240 explicitly accepts last-write-wins for the endpoint file). Hook payloads are a UX surface, not a correctness-critical store, so the trade-off matches. @@ -204,7 +210,7 @@ The renderer slice's `setAgentStatus` is otherwise unchanged, and `useRetainedAg **Settings flag flipped off after a session populated the file, then quit.** The flag flip itself triggered a one-shot delete of `last-status.json` (see Write Rules). The next launch with the gate off skips hydration regardless. Flipping the flag back on later starts from no on-disk state; the dashboard is empty until the next hook event repopulates the cache and the next scheduled write rewrites the file. -**Settings flag flipped off mid-session.** The next debounced write deletes `last-status.json` (idempotent) and subsequent writes are skipped while the flag stays off. The in-memory cache is *not* cleared so a later flip back on resumes from the same state. +**Settings flag flipped off mid-session.** The next debounced write deletes `last-status.json` (idempotent) and subsequent writes are skipped while the flag stays off. The in-memory cache is _not_ cleared so a later flip back on resumes from the same state. ## Implementation Notes @@ -216,19 +222,21 @@ Primary file: - Add `private hydrateLastStatusFromDisk()` invoked at the top of `start()` (gated on `getDashboardEnabled?.() === true`). Reads file, `JSON.parse`s the envelope, sanitizes field-by-field, validates each `entry.payload` via `normalizeAgentStatusPayload` (`src/shared/agent-status-types.ts:259` — accepts `unknown`, returns parsed `ParsedAgentStatusPayload | null`), and calls `lastStatusByPaneKey.set(...)` for survivors. Use `normalizeAgentStatusPayload`, not `parseAgentStatusPayload` — the latter takes a JSON string only and would force a wasteful stringify/parse round-trip. - Add `private scheduleStatusPersist()` and `private flushStatusPersistSync()`. Wire them into the three mutation sites: HTTP handler `set` (server.ts:1179), `clearPaneState` `delete` (server.ts:1262), and `stop()` `clear` (server.ts:1248). The `stop()` path uses the synchronous flush before `lastStatusByPaneKey.clear()`. - Add atomic write helper that mirrors the existing `writeEndpointFile()` pattern (tmp + rename, 0o600 mode, owner-only directory at 0o700 — server.ts:1349, 1395). Tmp filename: `.last-status--.tmp` (distinct prefix so a future filename change to one writer does not affect the other). Extend the existing orphan-tmp sweep at server.ts:1372 to match either prefix (`.endpoint-` OR `.last-status-`) so a crash mid-write of either file is cleaned up by either subsequent start. - - Wire `setListener()` unchanged: the existing replay loop at server.ts:1110 is exactly what's needed for the renderer to receive hydrated entries. + - Add `getStatusSnapshot()` so the renderer can pull the current hydrated map after its own settings/session state is ready. `setListener()` remains unchanged for the existing windowless-window recreation path. -- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})` at index.ts:560-567. Add an IPC handler for `agentStatus:drop` that calls `agentHookServer.clearPaneState(paneKey)` (gated on `experimentalAgentDashboard === true`). Existing `setListener` registration at index.ts:277 remains unchanged. +- `src/main/index.ts` — pass `getDashboardEnabled` to `agentHookServer.start({...})` at index.ts:560-567. Forward live `agentStatus:set` payloads with `receivedAt` and `stateStartedAt`. Existing `setListener` registration at index.ts:277 remains unchanged. -- `src/renderer/src/hooks/useIpcEvents.ts` — extend the `agentStatus.onSet` handler at line 816-849 with a bootstrap queue. When the dashboard gate is on, `resolvePaneKey(...).exists === false`, AND `workspaceSessionReady === false`, push `{ paneKey, payload, title }` into a module-level array (capped at ~200 entries) instead of dropping. Add a single subscription elsewhere in the same file (or in `useIpcEvents`'s mount effect) that observes `workspaceSessionReady` flipping to `true` and drains the queue: re-call `resolvePaneKey` for each entry, dropping any whose tab is still unknown (a closed-in-prior-session orphan), `setAgentStatus(...)` the rest, then clear the queue. +- `src/main/ipc/agent-hooks.ts` — add `agentStatus:getSnapshot`, gated on `experimentalAgentDashboard === true`, returning `agentHookServer.getStatusSnapshot()`. The existing `agentStatus:drop` handler continues to call `agentHookServer.clearPaneState(paneKey)`. -- `src/renderer/src/store/slices/agent-status.ts` — extend `dropAgentStatus` at line 316 to fire `window.api.agentStatus.drop(paneKey)` after its existing zustand `set(...)` block completes. Extend `dismissRetainedAgentsByWorktree` at line 578 to collect the dismissed paneKeys inside the `set(...)` block and fire one `window.api.agentStatus.drop(paneKey)` per removed key after the set returns. No other slice changes. +- `src/renderer/src/hooks/useIpcEvents.ts` — make the `agentStatus.onSet` handler require both the dashboard gate and `workspaceSessionReady`. Add a store subscription that calls `window.api.agentStatus.getSnapshot()` once when those conditions become true, re-resolves each paneKey, drops unknown-tab entries, and applies the rest through `setAgentStatus` with timing metadata. -- `src/preload/index.ts` and `src/preload/api-types.ts` — expose `agentStatus.drop(paneKey: string): void` (fire-and-forget). Mirror the existing `agentStatus.onSet` shape. +- `src/renderer/src/store/slices/agent-status.ts` — extend `setAgentStatus` to accept optional `{ updatedAt, stateStartedAt }` timing. Use those values for snapshot hydration, and ignore stale snapshot entries if a newer live push is already present. -Other renderer files (`src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, `src/renderer/src/App.tsx`): **no changes**. The retention slice and dashboard memos see the hydrated entries through the existing `setAgentStatus` path once the queue drains. +- `src/preload/index.ts` and `src/preload/api-types.ts` — expose `agentStatus.getSnapshot(): Promise`, keep `agentStatus.drop(paneKey: string): void`, and type `agentStatus.onSet` with the shared IPC payload. -`src/shared/types.ts`, `src/shared/constants.ts`, `src/shared/agent-status-types.ts`: **no changes**. (We reuse `normalizeAgentStatusPayload` from `shared/agent-status-types.ts` but do not modify it.) +Other renderer files (`src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, `src/renderer/src/App.tsx`): **no changes**. The retention slice and dashboard memos see the hydrated entries through the existing `setAgentStatus` path once the snapshot applies. + +`src/shared/agent-status-types.ts` — add `AgentStatusIpcPayload`, which extends the parsed payload with pane identity and timing fields shared by main, preload, and renderer. Tests: @@ -236,14 +244,14 @@ Tests: - Sanitization unit tests: bad version, missing entries, malformed paneKey, key/embedded paneKey mismatch, payload that fails `normalizeAgentStatusPayload`. - Persistence unit tests: a `set` schedules a write; the debounce coalesces two rapid `set`s into one write; identical-payload writes are skipped; `stop()` flushes synchronously; flipping the gate from on → off deletes the file (or no-ops if absent) and skips subsequent writes. - Hydration unit tests: starting with a populated file populates `lastStatusByPaneKey` before the HTTP listener binds; missing file is a no-op; corrupt file is a no-op + warn. - - Listener replay test: after hydrate, registering a listener replays the hydrated entries (this exercises the contract that hydration → setListener → renderer just works). + - Snapshot test: after hydrate, `getStatusSnapshot()` returns the hydrated entries with timing metadata. - Gate test: with `getDashboardEnabled` returning false, `start()` skips hydration and `set` skips writes. - Existing `useRetainedAgents.test.ts` runs unchanged. - New renderer test in the existing agent-status slice test file: `dropAgentStatus` fires `window.api.agentStatus.drop(paneKey)` exactly once after its zustand mutation; double-drop on the same paneKey only fires once (idempotent). -- New IPC handler test in the existing main-process test suite: receiving `agentStatus:drop` with the gate on calls `clearPaneState`; with the gate off, the call is a no-op. -- New bootstrap-queue test in `useIpcEvents.test.ts` (or equivalent renderer test): an `agentStatus:set` event for an unknown paneKey while `workspaceSessionReady === false` is queued, not dropped; flipping `workspaceSessionReady` to `true` drains the queue; entries whose tab is still unknown after the drain are silently discarded; queueing past the 200-entry cap drops the new entry, not the head. Test setup must reset the module-level queue between tests (export a `__resetBootstrapQueueForTests` helper from `useIpcEvents.ts` and call it in `beforeEach`). +- New IPC handler tests in the existing main-process test suite: `agentStatus:drop` with the gate on calls `clearPaneState`; with the gate off, the call is a no-op. `agentStatus:getSnapshot` returns cached entries with the gate on and an empty array with the gate off. +- New snapshot tests in `useIpcEvents.test.ts`: early `agentStatus:set` pushes before settings/session readiness are ignored, `getSnapshot()` is pulled once after readiness, entries whose tabs are still unknown are discarded, and no snapshot is requested while the dashboard gate is off. - New worktree-archive IPC fan-out test on the slice: calling `dismissRetainedAgentsByWorktree(worktreeId)` fires `window.api.agentStatus.drop(paneKey)` once for each retained paneKey under that worktree. -- Integration sanity check (manual or in the dashboard suite): a `done` agent finishes → quit → file is written → relaunch → renderer's `agentStatusByPaneKey` is populated within the first listener-registration tick → the inline list renders the row → dismiss → re-quit → relaunch → no row. +- Integration sanity check (manual or in the dashboard suite): a `done` agent finishes → quit → file is written → relaunch → renderer's `agentStatusByPaneKey` is populated after settings/session hydration and snapshot pull → the inline list renders the row → dismiss → re-quit → relaunch → no row. Telemetry: @@ -275,7 +283,7 @@ The renderer-side approach was strictly smaller code; the upstream approach is s ## Acceptance Criteria -- **Done row preservation.** Toggle the experimental agent dashboard on. Run an agent to completion. Quit and relaunch Orca. The completion row appears in the worktree's inline agents list as soon as the renderer registers the listener (within the first frame of dashboard render). +- **Done row preservation.** Toggle the experimental agent dashboard on. Run an agent to completion. Quit and relaunch Orca. The completion row appears in the worktree's inline agents list after renderer settings and workspace tabs hydrate and the snapshot pull completes. - **Blocked row preservation.** Same flow, but quit while a Claude is sitting at a permission prompt (`blocked`). After relaunch, the blocked row is visible with the same prompt text and tool name. - **Quiet working preservation.** Same flow, but quit while an agent is in `working` state and not currently emitting events. After relaunch, the row shows `working` with the last-known tool/prompt context. As soon as the agent emits its next event, the row updates naturally. - **Live overwrite.** A hydrated row is overwritten when a new hook event fires for the same paneKey post-restart. No UI artifacts (no flicker, no double-rendered row, no orphan retention). @@ -288,4 +296,4 @@ The renderer-side approach was strictly smaller code; the upstream approach is s - **Flag flipped off mid-session.** A populated cache exists in memory; the user toggles the flag off. The next scheduled write deletes `last-status.json` (idempotent if already absent) and subsequent scheduled writes are skipped. Memory state is unchanged. - **Flag flipped off, then quit, then on.** With the flag off, the file was deleted by the gate-flip-off path (or never created if the user never opted in). Quit. Relaunch with the flag back on; the file is absent → hydration is a no-op → the dashboard starts clean. The first new hook event after launch will recreate the file from a fresh write. - **Corrupt file.** Hand-edit `last-status.json` to invalid JSON. Relaunch. Single console warn, dashboard renders normally, no rows from the corrupt file. The first new event repopulates the file from a clean state. -- **Bounded renderer changes.** `src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, and `src/renderer/src/App.tsx` are not modified. Existing tests in `useRetainedAgents.test.ts` pass unchanged. The only renderer changes are: (a) the bootstrap queue in `useIpcEvents.ts`, (b) firing `window.api.agentStatus.drop(paneKey)` from `dropAgentStatus` in `agent-status.ts`, and (c) fanning out the same IPC over each removed paneKey in `dismissRetainedAgentsByWorktree`. +- **Bounded renderer changes.** `src/renderer/src/components/dashboard/useRetainedAgents.ts`, `src/renderer/src/components/dashboard/useDashboardData.ts`, and `src/renderer/src/App.tsx` are not modified. Existing tests in `useRetainedAgents.test.ts` pass unchanged. The renderer change is limited to snapshot application in `useIpcEvents.ts` and timing-aware `setAgentStatus(...)` hydration. diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 4b8b1baab09..20836b3b4fc 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -73,16 +73,20 @@ describe('AgentHookServer listener replay', () => { server.setListener(listener) expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ - state: 'working', - prompt: 'replay me', - agentType: 'claude' + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'replay me', + agentType: 'claude' + }) }) - }) + ) } finally { server.stop() } @@ -147,16 +151,20 @@ describe('AgentHookServer listener replay', () => { const listener = vi.fn() server.setListener(listener) - expect(listener).toHaveBeenCalledWith({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'repo::/tmp/worktree with "quotes"', - payload: expect.objectContaining({ - state: 'working', - prompt: 'form encoded', - agentType: 'claude' + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'repo::/tmp/worktree with "quotes"', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), + payload: expect.objectContaining({ + state: 'working', + prompt: 'form encoded', + agentType: 'claude' + }) }) - }) + ) } finally { server.stop() } @@ -1102,11 +1110,13 @@ describe('Last-status persistence', () => { server.flushStatusPersistSync() expect(existsSync(lastStatusPath())).toBe(true) const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) - expect(file.version).toBe(1) + expect(file.version).toBe(2) expect(file.entries[PANE]).toMatchObject({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + receivedAt: expect.any(Number), + stateStartedAt: expect.any(Number), payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) }) } finally { @@ -1170,12 +1180,14 @@ describe('Last-status persistence', () => { // Pre-populate the file directly to simulate a prior session. mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const fileContents = { - version: 1, + version: 2, entries: { [PANE]: { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'survived restart', @@ -1196,16 +1208,32 @@ describe('Last-status persistence', () => { const listener = vi.fn() server.setListener(listener) expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - payload: expect.objectContaining({ + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + payload: expect.objectContaining({ + state: 'done', + prompt: 'survived restart', + agentType: 'claude' + }) + }) + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, state: 'done', prompt: 'survived restart', agentType: 'claude' }) - }) + ]) } finally { server.stop() } @@ -1216,10 +1244,12 @@ describe('Last-status persistence', () => { writeFileSync( lastStatusPath(), JSON.stringify({ - version: 1, + version: 2, entries: { [PANE]: { paneKey: PANE, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'should not load', agentType: 'claude' } } } @@ -1267,10 +1297,12 @@ describe('Last-status persistence', () => { writeFileSync( lastStatusPath(), JSON.stringify({ - version: 0, + version: 1, entries: { [PANE]: { paneKey: PANE, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'old version', agentType: 'claude' } } } @@ -1288,6 +1320,7 @@ describe('Last-status persistence', () => { const listener = vi.fn() server.setListener(listener) expect(listener).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('version mismatch')) } finally { server.stop() warnSpy.mockRestore() @@ -1299,22 +1332,28 @@ describe('Last-status persistence', () => { writeFileSync( lastStatusPath(), JSON.stringify({ - version: 1, + version: 2, entries: { // Missing colon — drop. 'no-colon': { paneKey: 'no-colon', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'bad', agentType: 'claude' } }, // Embedded paneKey mismatch — drop. [PANE]: { paneKey: 'tab-x:99', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } }, // Valid. 'tab-good:0': { paneKey: 'tab-good:0', tabId: 'tab-good', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'survived', agentType: 'claude' } } } diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 215dc7dd448..40c6b1d4cc7 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -16,6 +16,7 @@ import { } from 'fs' import { join } from 'path' import { + type AgentStatusIpcPayload, normalizeAgentStatusPayload, parseAgentStatusPayload, type ParsedAgentStatusPayload @@ -43,13 +44,18 @@ import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' // https://cursor.com/docs/hooks. See normalizeCursorEvent below. type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' -type AgentHookEventPayload = { +type AgentHookEventBasePayload = { paneKey: string tabId?: string worktreeId?: string payload: ParsedAgentStatusPayload } +type AgentHookEventPayload = AgentHookEventBasePayload & { + receivedAt: number + stateStartedAt: number +} + // Why: only log a given version/env mismatch once per process so a stale hook // script that fires on every keystroke doesn't flood the logs. const warnedVersions = new Set() @@ -968,7 +974,7 @@ function normalizeHookPayload( source: AgentHookSource, body: unknown, expectedEnv: string -): AgentHookEventPayload | null { +): AgentHookEventBasePayload | null { if (typeof body !== 'object' || body === null) { return null } @@ -1072,7 +1078,7 @@ const LAST_STATUS_FILE_NAME = 'last-status.json' // Why: bumping this rejects on-disk files written by older shapes — see the // "Stale file from a prior Orca version" edge case in the design doc. A // mismatched version is treated as a corrupt file (silent empty hydration). -const LAST_STATUS_FILE_VERSION = 1 +const LAST_STATUS_FILE_VERSION = 2 // Why: trailing-edge debounce so a burst of hook events from a multi-agent // run produces one disk write instead of N. The latency budget matches other @@ -1121,6 +1127,18 @@ function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEve if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { return null } + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null + } + const stateStartedAt = record.stateStartedAt + if ( + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 + ) { + return null + } const payload = normalizeAgentStatusPayload(record.payload) if (!payload) { return null @@ -1129,7 +1147,20 @@ function sanitizeHydratedEntry(paneKey: string, rawEntry: unknown): AgentHookEve paneKey, tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, - payload + payload, + receivedAt, + stateStartedAt + } +} + +function toAgentStatusIpcPayload(entry: AgentHookEventPayload): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + tabId: entry.tabId, + worktreeId: entry.worktreeId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...entry.payload } } @@ -1177,9 +1208,8 @@ export class AgentHookServer { private lastStatusFilePath: string | null = null // Why: closure that reads the experimentalAgentDashboard setting at write // time. Passed in from index.ts so the hook server stays decoupled from - // the Store class. Null when unset → persistence runs (back-compat for - // callers that haven't wired the gate yet); the design doc gates on the - // flag, so production callers always pass a closure. + // the Store class. Null fails closed: future callers must opt into disk + // persistence explicitly. private getDashboardEnabled: (() => boolean) | null = null // Why: trailing-edge debounce timer. captured per-instance so multiple // server instances in the same process (tests) don't share state. @@ -1209,6 +1239,22 @@ export class AgentHookServer { } } + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(lastStatusByPaneKey.values(), toAgentStatusIpcPayload) + } + + private attachStatusTiming(payload: AgentHookEventBasePayload): AgentHookEventPayload { + const now = Date.now() + const previous = lastStatusByPaneKey.get(payload.paneKey) + const stateStartedAt = + previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + async start(options?: { env?: string userDataPath?: string @@ -1235,9 +1281,8 @@ export class AgentHookServer { this.deletedOnDisable = false // Why: hydrate before binding the HTTP listener so any new hook POST // (which goes through lastStatusByPaneKey.set) runs against an already- - // populated map. The setListener() replay loop on the next window - // creation then fans hydrated entries into the renderer through the - // existing IPC path. + // populated map. The renderer later pulls this map as a snapshot after + // its settings and workspace tabs are hydrated. if (this.lastStatusFilePath && this.isDashboardEnabled()) { this.hydrateLastStatusFromDisk() } @@ -1285,8 +1330,9 @@ export class AgentHookServer { return } - const payload = normalizeHookPayload(source, body, this.env) - if (payload) { + const normalized = normalizeHookPayload(source, body, this.env) + if (normalized) { + const payload = this.attachStatusTiming(normalized) lastStatusByPaneKey.set(payload.paneKey, payload) this.scheduleStatusPersist() this.onAgentStatus?.(payload) @@ -1552,12 +1598,12 @@ export class AgentHookServer { } } - // Why: callers that haven't wired the gate get persistence enabled by - // default — matches the back-compat posture for fields like getDashboardEnabled - // being optional. Production main always passes the closure; tests opt - // out by passing a closure that returns false. + // Why: fail closed when the gate isn't wired so a future caller of start() + // that forgets the closure cannot silently leak hook payloads to disk. + // Production main always passes the closure; tests that need persistence + // pass `getDashboardEnabled: () => true` explicitly. private isDashboardEnabled(): boolean { - return this.getDashboardEnabled ? this.getDashboardEnabled() === true : true + return this.getDashboardEnabled?.() === true } private hydrateLastStatusFromDisk(): void { @@ -1602,17 +1648,23 @@ export class AgentHookServer { return } let hydrated = 0 + let dropped = 0 for (const [paneKey, rawEntry] of Object.entries(entries)) { const entry = sanitizeHydratedEntry(paneKey, rawEntry) if (entry) { lastStatusByPaneKey.set(paneKey, entry) hydrated += 1 + } else { + dropped += 1 } } - if (hydrated > 0) { + if (hydrated > 0 && dropped === 0) { // Why: prime lastWrittenJson so an immediate scheduleStatusPersist() // (e.g. from a hook event that arrives before any change) does not - // re-write the file with byte-identical contents. + // re-write the file with byte-identical contents. Only prime when + // hydration was lossless — if entries were dropped during sanitize, + // the in-memory map diverges from the on-disk bytes; leaving the + // prime null forces the next write to clean up the corrupt entries. this.lastWrittenJson = this.serializeStatusFile() } } @@ -1674,15 +1726,23 @@ export class AgentHookServer { if (this.deletedOnDisable) { return } + // Why: a transient unlink failure must not permanently suppress retries — + // the gate is OFF and the file must come off disk on a future tick. + let removed = false try { unlinkSync(this.lastStatusFilePath) + removed = true } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + removed = true + } else { console.warn('[agent-hooks] failed to delete last-status file:', err) } } - this.deletedOnDisable = true - this.lastWrittenJson = null + if (removed) { + this.deletedOnDisable = true + this.lastWrittenJson = null + } return } // Why: the gate is back on after being off — clear the suppression diff --git a/src/main/index.ts b/src/main/index.ts index 6eeb3589c90..f1b04c64b26 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -274,35 +274,39 @@ function openMainWindow(): BrowserWindow { } }) mainWindow = window - agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { - if (mainWindow?.isDestroyed()) { - return + agentHookServer.setListener( + ({ paneKey, tabId, worktreeId, payload, receivedAt, stateStartedAt }) => { + if (mainWindow?.isDestroyed()) { + return + } + // Why: only forward status events to the renderer when the user has + // opted into the experimental dashboard. Reading the current setting + // here (rather than a module-level snapshot) lets the gate flip live + // for the renderer-side surfaces — the hook server itself always runs. + if (store?.getSettings().experimentalAgentDashboard === true) { + mainWindow?.webContents.send('agentStatus:set', { + paneKey, + tabId, + worktreeId, + receivedAt, + stateStartedAt, + ...payload + }) + } + // Why: cursor-agent emits no title-based working/idle signal — its OSC + // title stays "Cursor Agent" for the whole turn. Synthesize an OSC title + // update from the hook state and inject it into the pane's data stream so + // the existing renderer-side title tracker (the one that drives the + // sidebar spinner, unread badge, and Claude prompt-cache timer for every + // other agent) lights up for cursor panes too. Braille prefix ⠋ → working + // keyword path; "action required" keyword → permission; bare label → idle. + // This runs regardless of the dashboard setting because cursor has no + // pre-dashboard title heuristic to fall back to. + if (payload.agentType === 'cursor') { + driveCursorPaneFromHook(paneKey, payload.state) + } } - // Why: only forward status events to the renderer when the user has - // opted into the experimental dashboard. Reading the current setting - // here (rather than a module-level snapshot) lets the gate flip live - // for the renderer-side surfaces — the hook server itself always runs. - if (store?.getSettings().experimentalAgentDashboard === true) { - mainWindow?.webContents.send('agentStatus:set', { - paneKey, - tabId, - worktreeId, - ...payload - }) - } - // Why: cursor-agent emits no title-based working/idle signal — its OSC - // title stays "Cursor Agent" for the whole turn. Synthesize an OSC title - // update from the hook state and inject it into the pane's data stream so - // the existing renderer-side title tracker (the one that drives the - // sidebar spinner, unread badge, and Claude prompt-cache timer for every - // other agent) lights up for cursor panes too. Braille prefix ⠋ → working - // keyword path; "action required" keyword → permission; bare label → idle. - // This runs regardless of the dashboard setting because cursor has no - // pre-dashboard title heuristic to fall back to. - if (payload.agentType === 'cursor') { - driveCursorPaneFromHook(paneKey, payload.state) - } - }) + ) return window } diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index b8445d9fd58..7ef236f6e58 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -6,13 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // renderer cannot churn the persistence path. const clearPaneState = vi.fn() +const getStatusSnapshot = vi.fn() const onHandlers = new Map void>() +const handleHandlers = new Map unknown>() const removeHandler = vi.fn() const removeAllListeners = vi.fn() vi.mock('electron', () => ({ ipcMain: { - handle: vi.fn(), + handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => { + handleHandlers.set(channel, handler) + }, on: (channel: string, handler: (event: unknown, ...args: unknown[]) => void) => { onHandlers.set(channel, handler) }, @@ -23,7 +27,8 @@ vi.mock('electron', () => ({ vi.mock('../agent-hooks/server', () => ({ agentHookServer: { - clearPaneState + clearPaneState, + getStatusSnapshot } })) @@ -42,7 +47,9 @@ vi.mock('../cursor/hook-service', () => ({ beforeEach(() => { clearPaneState.mockReset() + getStatusSnapshot.mockReset() onHandlers.clear() + handleHandlers.clear() removeHandler.mockReset() removeAllListeners.mockReset() }) @@ -51,6 +58,44 @@ afterEach(() => { vi.resetModules() }) +describe('agentStatus:getSnapshot IPC', () => { + it('returns the hook cache snapshot when the experimental dashboard is on', async () => { + const snapshot = [ + { + paneKey: 'tab-1:0', + state: 'done', + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ] + getStatusSnapshot.mockReturnValue(snapshot) + const { registerAgentHookHandlers } = await import('./agent-hooks') + const store = { + getSettings: () => ({ experimentalAgentDashboard: true }) + } as { getSettings: () => { experimentalAgentDashboard: boolean } } + registerAgentHookHandlers(store as unknown as Parameters[0]) + + const handler = handleHandlers.get('agentStatus:getSnapshot') + expect(handler).toBeDefined() + expect(handler!({})).toEqual(snapshot) + }) + + it('returns an empty snapshot when the experimental dashboard is off', async () => { + getStatusSnapshot.mockReturnValue([{ paneKey: 'tab-1:0' }]) + const { registerAgentHookHandlers } = await import('./agent-hooks') + const store = { + getSettings: () => ({ experimentalAgentDashboard: false }) + } as { getSettings: () => { experimentalAgentDashboard: boolean } } + registerAgentHookHandlers(store as unknown as Parameters[0]) + + const handler = handleHandlers.get('agentStatus:getSnapshot')! + expect(handler({})).toEqual([]) + expect(getStatusSnapshot).not.toHaveBeenCalled() + }) +}) + describe('agentStatus:drop IPC', () => { it('forwards drop to clearPaneState when the experimental dashboard is on', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') @@ -84,9 +129,10 @@ describe('agentStatus:drop IPC', () => { registerAgentHookHandlers(store as unknown as Parameters[0]) const handler = onHandlers.get('agentStatus:drop')! - handler({}, 123) - handler({}, undefined) - handler({}, '') + const bad: unknown[] = [123, undefined, '', null, {}, []] + for (const value of bad) { + expect(() => handler({}, value)).not.toThrow() + } expect(clearPaneState).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index a6d0ea57378..6e1d6688961 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -22,6 +22,7 @@ export function registerAgentHookHandlers(store: Store): void { ipcMain.removeHandler('agentHooks:codexStatus') ipcMain.removeHandler('agentHooks:geminiStatus') ipcMain.removeHandler('agentHooks:cursorStatus') + ipcMain.removeHandler('agentStatus:getSnapshot') // Why: agentStatus:drop is sent fire-and-forget from the renderer via // ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't // round-trip a response. Removing first keeps re-registration safe even @@ -37,7 +38,25 @@ export function registerAgentHookHandlers(store: Store): void { if (store.getSettings().experimentalAgentDashboard !== true) { return } - agentHookServer.clearPaneState(paneKey) + try { + agentHookServer.clearPaneState(paneKey) + } catch (err) { + console.warn('[agent-hooks] clearPaneState failed:', err) + } + }) + ipcMain.handle('agentStatus:getSnapshot', () => { + // Why: the renderer pulls this after settings + workspace hydration, so + // startup cannot lose replayed statuses while its local store is still + // empty. Keep the same opt-in gate as push delivery and disk writes. + try { + if (store.getSettings().experimentalAgentDashboard !== true) { + return [] + } + return agentHookServer.getStatusSnapshot() + } catch (err) { + console.warn('[agent-hooks] getStatusSnapshot failed:', err) + return [] + } }) // Why: errors from getStatus() (fs permission denied, homedir resolution diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3c4a7f6ef48..92def29e185 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -114,7 +114,7 @@ import type { ElectronAPI } from '@electron-toolkit/preload' import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' -import type { AgentStatusState } from '../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { DeveloperPermissionId, @@ -1148,20 +1148,9 @@ export type PreloadApi = { } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: ( - callback: (data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - }) => void - ) => () => void + onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void + /** Return the current main-process hook cache after renderer hydration. */ + getSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and the on-disk * last-status file. Fire-and-forget. */ drop: (paneKey: string) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index e694c2d932f..33146ab2061 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,7 +61,7 @@ import type { PortForwardEntry, DetectedPort } from '../shared/ssh-types' -import type { AgentStatusState } from '../shared/agent-status-types' +import type { AgentStatusIpcPayload } from '../shared/agent-status-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { @@ -2149,38 +2149,17 @@ const api = { agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: ( - callback: (data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - paneKey: string - tabId?: string - worktreeId?: string - state: AgentStatusState - prompt?: string - agentType?: string - toolName?: string - toolInput?: string - lastAssistantMessage?: string - interrupted?: boolean - } - ) => callback(data) + onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => + callback(data) ipcRenderer.on('agentStatus:set', listener) return () => ipcRenderer.removeListener('agentStatus:set', listener) }, + /** Pull the current cached hook statuses after renderer settings/session + * hydration. This avoids losing startup replays before the renderer + * knows whether the dashboard is enabled or which tabs exist. */ + getSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getSnapshot'), /** Drop the cached hook status for a paneKey on both sides — main-process * cache (lastStatusByPaneKey) and on-disk last-status file. Fired from * the renderer when the user dismisses a retained row so a relaunch diff --git a/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts b/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts deleted file mode 100644 index 4ef7d175c01..00000000000 --- a/src/renderer/src/hooks/agent-status-bootstrap-queue.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - __getAgentStatusBootstrapQueueLengthForTests as queueLength, - BOOTSTRAP_QUEUE_MAX, - drainAgentStatusBootstrapQueue, - enqueueAgentStatusBootstrap, - isBootstrapQueueDrained, - resetAgentStatusBootstrapQueue -} from './agent-status-bootstrap-queue' -import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' - -const PAYLOAD: ParsedAgentStatusPayload = { - state: 'done', - prompt: 'p', - agentType: 'claude' -} - -beforeEach(() => { - resetAgentStatusBootstrapQueue() -}) - -describe('agent-status bootstrap queue', () => { - it('enqueues until drained, then drops post-drain entries', () => { - expect( - enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) - ).toBe(true) - expect(queueLength()).toBe(1) - - const visitor = vi.fn() - drainAgentStatusBootstrapQueue(visitor) - expect(visitor).toHaveBeenCalledTimes(1) - expect(visitor).toHaveBeenCalledWith({ - paneKey: 'tab-a:0', - payload: PAYLOAD, - title: undefined - }) - expect(isBootstrapQueueDrained()).toBe(true) - - // Post-drain enqueue is a no-op so the renderer's normal "unknown tab" - // drop path takes over for genuine orphans (closed tabs). - expect( - enqueueAgentStatusBootstrap({ paneKey: 'tab-b:0', payload: PAYLOAD, title: undefined }) - ).toBe(false) - expect(queueLength()).toBe(0) - }) - - it('drain is idempotent', () => { - enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) - const visitor = vi.fn() - drainAgentStatusBootstrapQueue(visitor) - drainAgentStatusBootstrapQueue(visitor) - expect(visitor).toHaveBeenCalledTimes(1) - }) - - it('drops new entries when capped (preserves the head — earliest entries win under overflow)', () => { - for (let i = 0; i < BOOTSTRAP_QUEUE_MAX; i++) { - expect( - enqueueAgentStatusBootstrap({ paneKey: `tab-${i}:0`, payload: PAYLOAD, title: undefined }) - ).toBe(true) - } - expect(queueLength()).toBe(BOOTSTRAP_QUEUE_MAX) - expect( - enqueueAgentStatusBootstrap({ paneKey: 'tab-overflow:0', payload: PAYLOAD, title: undefined }) - ).toBe(false) - - const seen: string[] = [] - drainAgentStatusBootstrapQueue((e) => seen.push(e.paneKey)) - expect(seen).toContain('tab-0:0') - expect(seen).not.toContain('tab-overflow:0') - }) - - it('reset clears state including the drained flag', () => { - enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) - drainAgentStatusBootstrapQueue(() => {}) - expect(isBootstrapQueueDrained()).toBe(true) - resetAgentStatusBootstrapQueue() - expect(isBootstrapQueueDrained()).toBe(false) - // After reset, enqueue is allowed again. - expect( - enqueueAgentStatusBootstrap({ paneKey: 'tab-a:0', payload: PAYLOAD, title: undefined }) - ).toBe(true) - }) -}) diff --git a/src/renderer/src/hooks/agent-status-bootstrap-queue.ts b/src/renderer/src/hooks/agent-status-bootstrap-queue.ts deleted file mode 100644 index 886f32645d9..00000000000 --- a/src/renderer/src/hooks/agent-status-bootstrap-queue.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Why: hook events forwarded by setListener() during the main-process hook -// server start() arrive synchronously during window creation, before App.tsx -// finishes its async hydration of repos + worktrees + tabs (which is what -// populates tabsByWorktree). Without a queue, resolvePaneKey() returns -// exists=false and the event is dropped — every hydrated entry from the on- -// disk last-status cache would be invisible. Buffer "tab not yet known" -// events while workspaceSessionReady is false and drain them on the -// false→true transition. - -import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' - -export type QueuedAgentStatusEvent = { - paneKey: string - payload: ParsedAgentStatusPayload - title: string | undefined -} - -// Why: cap to keep memory bounded if something pathological holds -// workspaceSessionReady at false (e.g. a hydration failure in App.tsx). 200 is -// well above the realistic worst case of "agents per Orca user" and keeps the -// arrival order of the earliest entries — the ones with the strongest claim on -// dashboard state — intact under overflow (drop new entries, not head). -export const BOOTSTRAP_QUEUE_MAX = 200 - -let queue: QueuedAgentStatusEvent[] = [] -let drained = false - -export function enqueueAgentStatusBootstrap(entry: QueuedAgentStatusEvent): boolean { - if (drained) { - return false - } - if (queue.length >= BOOTSTRAP_QUEUE_MAX) { - return false - } - queue.push(entry) - return true -} - -export function isBootstrapQueueDrained(): boolean { - return drained -} - -/** Drain the queue exactly once. Subsequent calls no-op. The visitor receives - * each queued event and decides whether to apply it (returning a truthy - * value isn't required — the visitor handles its own application). */ -export function drainAgentStatusBootstrapQueue( - visitor: (event: QueuedAgentStatusEvent) => void -): void { - if (drained) { - return - } - drained = true - // Why: snapshot then clear before iterating so a re-entrant push during - // visitor() (race with a new live hook event landing on the same paneKey) - // is silently rejected rather than processed twice. - const snapshot = queue - queue = [] - for (const entry of snapshot) { - visitor(entry) - } -} - -/** Reset between renders/tests. Called from useIpcEvents's mount effect so - * HMR / Strict Mode double-mount cannot accumulate stale entries; also - * exported for direct test access. */ -export function resetAgentStatusBootstrapQueue(): void { - queue = [] - drained = false -} - -export function __getAgentStatusBootstrapQueueLengthForTests(): number { - return queue.length -} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index dcbefabf249..406dabe5a2d 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1653,3 +1653,372 @@ describe('useIpcEvents CLI-created worktree activation', () => { expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-new', { setup }) }) }) + +// Why: end-to-end exercise of startup agent-status restoration through +// useIpcEvents itself. The main process owns the durable cache; the renderer +// pulls a snapshot only after settings + workspace tabs are ready so startup +// pushes cannot be lost while local state is still empty. +describe('useIpcEvents agent status snapshot integration', () => { + type AgentStatusSetData = { + paneKey: string + tabId?: string + worktreeId?: string + state: 'working' | 'blocked' | 'waiting' | 'done' + prompt?: string + agentType?: string + toolName?: string + toolInput?: string + lastAssistantMessage?: string + interrupted?: boolean + receivedAt: number + stateStartedAt: number + } + type StoreLike = Record + type StoreSubscribeListener = (state: StoreLike) => void + + function buildStoreState(overrides: StoreLike): StoreLike { + // Why: copy the defensive set of getState() fields the hook touches during + // mount so individual tests only need to override workspaceSessionReady, + // tabsByWorktree, and setAgentStatus. + return { + setUpdateStatus: vi.fn(), + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + updateBrowserTabPageState: vi.fn(), + activeTabType: 'terminal', + editorFontZoomLevel: 0, + setEditorFontZoomLevel: vi.fn(), + setRateLimitsFromPush: vi.fn(), + setSshConnectionState: vi.fn(), + setSshTargetLabels: vi.fn(), + setPortForwards: vi.fn(), + clearPortForwards: vi.fn(), + setDetectedPorts: vi.fn(), + enqueueSshCredentialRequest: vi.fn(), + removeSshCredentialRequest: vi.fn(), + clearTabPtyId: vi.fn(), + runtimePaneTitlesByTabId: {}, + tabsByWorktree: {}, + workspaceSessionReady: false, + settings: { terminalFontSize: 13, experimentalAgentDashboard: true }, + ...overrides + } + } + + function buildWindowApi(args: { + onSet: (cb: (data: AgentStatusSetData) => void) => () => void + getSnapshot?: () => Promise + drop?: (paneKey: string) => void + }): Record { + return { + api: { + repos: { onChanged: () => () => {} }, + worktrees: { onChanged: () => () => {} }, + ui: { + onOpenSettings: () => () => {}, + onToggleLeftSidebar: () => () => {}, + onToggleRightSidebar: () => () => {}, + onToggleWorktreePalette: () => () => {}, + onOpenQuickOpen: () => () => {}, + onOpenNewWorkspace: () => () => {}, + onJumpToWorktreeIndex: () => () => {}, + onWorktreeHistoryNavigate: () => () => {}, + onActivateWorktree: () => () => {}, + onCreateTerminal: () => () => {}, + onRequestTerminalCreate: () => () => {}, + replyTerminalCreate: () => {}, + onSplitTerminal: () => () => {}, + onRenameTerminal: () => () => {}, + onFocusTerminal: () => () => {}, + onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, + onNewBrowserTab: () => () => {}, + onRequestTabCreate: () => () => {}, + replyTabCreate: () => {}, + onRequestTabClose: () => () => {}, + replyTabClose: () => {}, + onRequestTabSetProfile: () => () => {}, + replyTabSetProfile: () => {}, + onNewTerminalTab: () => () => {}, + onCloseActiveTab: () => () => {}, + onSwitchTab: () => () => {}, + onSwitchTabAcrossAllTypes: () => () => {}, + onSwitchTerminalTab: () => () => {}, + onToggleStatusBar: () => () => {}, + onFullscreenChanged: () => () => {}, + onTerminalZoom: () => () => {}, + getZoomLevel: () => 0, + set: vi.fn() + }, + settings: { onChanged: () => () => {} }, + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + browser: { + onGuestLoadFailed: () => () => {}, + onOpenLinkInOrcaTab: () => () => {}, + onNavigationUpdate: () => () => {}, + onActivateView: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + agentStatus: { + onSet: args.onSet, + getSnapshot: args.getSnapshot ?? vi.fn(() => Promise.resolve([])), + drop: args.drop ?? vi.fn() + } + } + } + } + + function stubReactSyncEffect(): void { + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + } + + function stubAuxiliaryModules(): void { + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + } + + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + }) + + it('ignores early push events but applies the main-process snapshot after readiness', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => + Promise.resolve([ + { + paneKey: 'tab-future:0', + state: 'working' as const, + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ]) + ) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + tabsByWorktree: {}, + workspaceSessionReady: false + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn((listener: StoreSubscribeListener) => { + subscribeListenerRef.current = listener + return () => { + subscribeListenerRef.current = null + } + }), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + // Fire an event for an unknown paneKey while not ready — must NOT call setAgentStatus. + onSetListenerRef.current({ + paneKey: 'tab-future:0', + state: 'working', + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + expect(setAgentStatus).not.toHaveBeenCalled() + expect(getSnapshot).not.toHaveBeenCalled() + + storeState.workspaceSessionReady = true + storeState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + } + if (typeof subscribeListenerRef.current !== 'function') { + throw new Error('Expected useAppStore.subscribe listener to be registered') + } + subscribeListenerRef.current(storeState) + await Promise.resolve() + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + 'tab-future:0', + expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), + 'Future Tab', + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 } + ) + }) + + it('waits for settings before pulling the snapshot', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => Promise.resolve([])) + const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + tabsByWorktree: {}, + workspaceSessionReady: true, + settings: null + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn((listener: StoreSubscribeListener) => { + subscribeListenerRef.current = listener + return () => { + subscribeListenerRef.current = null + } + }), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + expect(getSnapshot).not.toHaveBeenCalled() + + storeState.settings = { terminalFontSize: 13, experimentalAgentDashboard: true } + subscribeListenerRef.current?.(storeState) + + expect(getSnapshot).toHaveBeenCalledTimes(1) + expect(setAgentStatus).not.toHaveBeenCalled() + }) + + it('silently discards snapshot entries whose tabs are still unknown', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => + Promise.resolve([ + { + paneKey: 'tab-orphan:0', + state: 'done' as const, + prompt: 'p', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ]) + ) + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-other', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Other' }] + }, + workspaceSessionReady: true + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + await Promise.resolve() + + expect(setAgentStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 38570da368e..70455743a9d 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -20,22 +20,18 @@ import { handleSwitchTerminalTab } from './ipc-tab-switch' import { dispatchClearModifierHints } from './useModifierHint' -import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { + normalizeAgentStatusPayload, + type AgentStatusIpcPayload +} from '../../../shared/agent-status-types' import { isGitRepoKind } from '../../../shared/repo-kind' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' -export { resetAgentStatusBootstrapQueue as __resetAgentStatusBootstrapQueueForTests } from './agent-status-bootstrap-queue' export { resolveZoomTarget } from './resolve-zoom-target' -import { - drainAgentStatusBootstrapQueue, - enqueueAgentStatusBootstrap, - resetAgentStatusBootstrapQueue -} from './agent-status-bootstrap-queue' - const ZOOM_STEP = 0.5 export function useIpcEvents(): void { @@ -819,83 +815,84 @@ export function useIpcEvents(): void { // unconditional so flipping the experimental dashboard setting takes // effect without re-running this App-mount effect; the per-event guard // inside the handler drops payloads when the setting is off. - // Why: reset the queue every effect mount so HMR / Strict Mode double-mount - // cannot accumulate stale entries across remounts. - resetAgentStatusBootstrapQueue() + const applyAgentStatus = (data: AgentStatusIpcPayload): void => { + const store = useAppStore.getState() + if (store.settings?.experimentalAgentDashboard !== true || !store.workspaceSessionReady) { + return + } + const payload = normalizeAgentStatusPayload({ + state: data.state, + prompt: data.prompt, + agentType: data.agentType, + toolName: data.toolName, + toolInput: data.toolInput, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted + }) + if (!payload) { + return + } + const { exists, title } = resolvePaneKey(store, data.paneKey) + if (!exists) { + return + } + store.setAgentStatus(data.paneKey, payload, title, { + updatedAt: data.receivedAt, + stateStartedAt: data.stateStartedAt + }) + } + + let snapshotRequestedForReadyWindow = false + let snapshotRequestId = 0 + const requestAgentStatusSnapshotIfReady = (): void => { + const store = useAppStore.getState() + if (store.settings?.experimentalAgentDashboard !== true || !store.workspaceSessionReady) { + snapshotRequestedForReadyWindow = false + return + } + if (snapshotRequestedForReadyWindow) { + return + } + const getSnapshot = window.api.agentStatus.getSnapshot + if (typeof getSnapshot !== 'function') { + return + } + snapshotRequestedForReadyWindow = true + const requestId = ++snapshotRequestId + void getSnapshot() + .then((entries) => { + if (requestId !== snapshotRequestId) { + return + } + const current = useAppStore.getState() + if ( + current.settings?.experimentalAgentDashboard !== true || + !current.workspaceSessionReady + ) { + return + } + for (const entry of entries) { + applyAgentStatus(entry) + } + }) + .catch((err) => { + snapshotRequestedForReadyWindow = false + console.warn('[agent-status] failed to load startup snapshot:', err) + }) + } + unsubs.push( window.api.agentStatus.onSet((data) => { - const store = useAppStore.getState() - if (store.settings?.experimentalAgentDashboard !== true) { - return - } - // Why: the IPC payload is already a structured object — pass it - // straight to the object-input normalizer instead of round-tripping - // through JSON.stringify/JSON.parse. Hook events can fire many times - // per second during a tool-use run, so this avoids the per-event - // serialization cost. - const payload = normalizeAgentStatusPayload({ - state: data.state, - prompt: data.prompt, - agentType: data.agentType, - toolName: data.toolName, - toolInput: data.toolInput, - lastAssistantMessage: data.lastAssistantMessage, - interrupted: data.interrupted - }) - if (!payload) { - return - } - // Why: resolve tab existence and terminal title in a single pass. - // Previously the code walked store.tabsByWorktree twice — once to - // look up the tab-level title (when the pane-level title was - // missing) and again for the explicit tabExists check. A paneKey - // that no longer resolves to a live tab belongs to a pane that has - // already been torn down; dropping here prevents orphan entries - // from accumulating in agentStatusByPaneKey. - const { exists, title } = resolvePaneKey(store, data.paneKey) - if (!exists) { - // Why: events for unknown tabs that arrive before workspaceSessionReady - // are most likely hydrated entries from the on-disk last-status cache - // replayed by the main-process setListener() during window creation - // (which runs synchronously before App.tsx's async hydration completes). - // Buffer them and drain on the false→true transition so the dashboard - // sees them once tabsByWorktree is populated. Post-drain, fall back to - // the original drop-on-unknown-tab behavior — those are real orphans - // (closed tabs) that must not pin entries in the store. - if (!store.workspaceSessionReady) { - enqueueAgentStatusBootstrap({ paneKey: data.paneKey, payload, title }) - } - return - } - store.setAgentStatus(data.paneKey, payload, title) + applyAgentStatus(data) }) ) - // Why: drain the bootstrap queue on the workspaceSessionReady false→true - // transition. Re-resolve each paneKey at drain time — a tab that was closed - // in the prior session won't be in the new tabsByWorktree, and dropping - // those silently is the right behavior (matches the "Tab closed between - // sessions" edge case in the design doc). - const drainNow = (): void => { - const store = useAppStore.getState() - drainAgentStatusBootstrapQueue((entry) => { - const { exists, title } = resolvePaneKey(store, entry.paneKey) - if (!exists) { - return - } - store.setAgentStatus(entry.paneKey, entry.payload, title ?? entry.title) - }) - } - if (useAppStore.getState().workspaceSessionReady) { - drainNow() - } else { - const unsubscribe = useAppStore.subscribe((state) => { - if (state.workspaceSessionReady) { - drainNow() - } - }) - unsubs.push(unsubscribe) - } + // Why: the main hook server is the durable source of truth. Pull a + // snapshot only after renderer settings and workspace tabs are ready, so + // early startup pushes can be safely ignored instead of buffered against + // partially hydrated renderer state. + requestAgentStatusSnapshotIfReady() + unsubs.push(useAppStore.subscribe(() => requestAgentStatusSnapshotIfReady())) // Why: hydrate mobile-fit overrides before terminal panes run their first // attach/fit logic, so a renderer reload doesn't undo active mobile fits. diff --git a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts index 77f6ec78563..628529dd007 100644 --- a/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts +++ b/src/renderer/src/store/slices/agent-status-freshness-scheduler.ts @@ -42,12 +42,9 @@ export function createFreshnessScheduler(deps: FreshnessSchedulerDeps): Freshnes // the timer forever because the bump doesn't clear them from the map // (retention is intentional so freshness-aware selectors can decay). // - // Invariant: the agent-status slice always writes `updatedAt = Date.now()` - // on insert/update (see `setAgentStatus` in agent-status.ts), so a - // newly-scheduled entry cannot already be stale here. If that invariant - // ever breaks (e.g. persistence rehydrate, IPC-synced backdated entries), - // the scheduler would silently no-op and miss the epoch bump — revisit - // this filter at that time. + // Snapshot hydration can insert already-stale entries. Those need no + // future timer: the setAgentStatus write already bumped the epoch, so + // freshness-aware selectors can decay them immediately on that render. for (const entry of entries) { const expiryAt = entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS if (expiryAt > now) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 4034684c483..629d36b3e54 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: this file is the umbrella suite for the agent-status slice (freshness, tool/assistant fields, stateStartedAt, retention + prefix sweep). Splitting by sub-area would scatter shared helpers (createTestStore, fake timers); narrower edge-cases live in sibling agent-status-*.test.ts files already. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, @@ -193,6 +194,54 @@ describe('agent status stateStartedAt', () => { expect(entry.stateHistory[0].state).toBe('working') expect(entry.stateHistory[0].startedAt).toBe(workingStart) }) + + it('uses IPC snapshot timing instead of restamping restored entries as fresh', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z')) + + const store = createTestStore() + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'p1', agentType: 'claude' }, + 'claude', + { + updatedAt: new Date('2026-04-09T10:00:00.000Z').getTime(), + stateStartedAt: new Date('2026-04-09T09:55:00.000Z').getTime() + } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.updatedAt).toBe(new Date('2026-04-09T10:00:00.000Z').getTime()) + expect(entry.stateStartedAt).toBe(new Date('2026-04-09T09:55:00.000Z').getTime()) + }) + + it('ignores an older snapshot when a newer live event already updated the pane', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'working', prompt: 'fresh', agentType: 'claude' }, + 'claude', + { updatedAt: 2_000, stateStartedAt: 2_000 } + ) + store + .getState() + .setAgentStatus( + 'tab-1:1', + { state: 'done', prompt: 'stale', agentType: 'claude' }, + 'claude', + { updatedAt: 1_000, stateStartedAt: 1_000 } + ) + + const entry = store.getState().agentStatusByPaneKey['tab-1:1'] + expect(entry.state).toBe('working') + expect(entry.prompt).toBe('fresh') + expect(entry.updatedAt).toBe(2_000) + }) }) describe('agent status retention + prefix sweep', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index df4a5111d3c..a7875b44183 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,7 +51,8 @@ export type AgentStatusSlice = { setAgentStatus: ( paneKey: string, payload: ParsedAgentStatusPayload, - terminalTitle?: string + terminalTitle?: string, + timing?: { updatedAt?: number; stateStartedAt?: number } ) => void /** Remove a single entry (e.g., when a pane's terminal exits). */ @@ -121,9 +122,16 @@ export const createAgentStatusSlice: StateCreator { + setAgentStatus: (paneKey, payload, terminalTitle, timing) => { + const updatedAt = timing?.updatedAt ?? Date.now() set((s) => { const existing = s.agentStatusByPaneKey[paneKey] + // Why: startup snapshots are pulled asynchronously. If a newer live + // push already landed for this pane, ignore the older snapshot entry + // instead of rolling the visible status backward. + if (existing && updatedAt < existing.updatedAt) { + return s + } // Why: terminalTitle is identity-like — it labels the pane itself, not // the current turn's activity. Preserve the prior value when a ping // omits it so the pane label does not flicker out between hook events. @@ -158,13 +166,14 @@ export const createAgentStatusSlice: StateCreator & { prompt: string } +export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { + paneKey: string + tabId?: string + worktreeId?: string + /** Timestamp (ms) when the hook server received this latest status event. */ + receivedAt: number + /** Timestamp (ms) when the current state first appeared for this pane. */ + stateStartedAt: number +} + /** Maximum character length for the prompt field. Truncated on parse. */ export const AGENT_STATUS_MAX_FIELD_LENGTH = 200 /** Maximum character length for the toolName field. */