From 75c3857efbc004fd47b141d9aee43bb02c778ae5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:32:15 -0700 Subject: [PATCH 1/7] fix(agent-status): renderer and recorder consumers read the question they mean Since #22295 a row's combined `state` reads `working` both while the lead's turn runs and while a subagent or background shell outlives a settled lead. The lead's own state now rides beside it (`lead`); each consumer in this slice reads the question it actually asks. - Smart sort and the Activity unread badge keep reading the combined state: their classes and rows are what the sidebar shows. Pinned with tests, including a restored `lead.state: 'working'` row that must never read live. - The stats recorder asks "was an agent executing" and now reads a shared derivation (`isAgentExecutionOwed`): the lead's turn, or live agent child work holding a settled lead's row open. A settled lead's background shell no longer accrues "Time agents worked". Old hosts without `lead` fall back to today's read; restored and replayed rows still never open a session. - The `agent.status.changed` plugin event gains `lead` as an optional field through one tested projection; `state` keeps its meaning and restored rows still project to nothing. - A Codex root Stop that follows an inferred interrupt keeps the `cancellation` verdict, as the Claude lane already does at its turn boundary, on both the hook and relay paths. --- .../plugins/plugin-agent-status-event.test.ts | 84 ++++++++++ src/main/plugins/plugin-agent-status-event.ts | 35 ++++ src/main/startup/main-process-plugins.ts | 13 +- .../agent-session-transition-recorder.test.ts | 152 +++++++++++++++++- .../agent-session-transition-recorder.ts | 68 ++++++-- .../activity/useActivityUnreadCount.test.ts | 45 ++++++ .../sidebar/smart-attention.test.ts | 47 +++++- src/shared/agent-lead-status-fold.test.ts | 32 ++++ src/shared/agent-lead-status-fold.ts | 24 +++ src/shared/plugins/plugin-events.ts | 15 +- 10 files changed, 486 insertions(+), 29 deletions(-) create mode 100644 src/main/plugins/plugin-agent-status-event.test.ts create mode 100644 src/main/plugins/plugin-agent-status-event.ts diff --git a/src/main/plugins/plugin-agent-status-event.test.ts b/src/main/plugins/plugin-agent-status-event.test.ts new file mode 100644 index 000000000000..06a1c3273b4b --- /dev/null +++ b/src/main/plugins/plugin-agent-status-event.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { agentStatusChangedPayloadSchema } from '../../shared/plugins/plugin-events' +import { projectPluginAgentStatusChangedPayload } from './plugin-agent-status-event' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function row( + overrides: Partial[0]> = {} +): Parameters[0] { + return { + paneKey: PANE, + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + payload: { state: 'working', prompt: 'ship it', agentType: 'claude' }, + ...overrides + } +} + +describe('projectPluginAgentStatusChangedPayload', () => { + it('publishes the lead fact beside the combined state, and the schema admits it', () => { + const projected = projectPluginAgentStatusChangedPayload( + row({ + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'claude', + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + } + }) + ) + expect(projected).toEqual({ + worktreeId: 'wt-1', + paneKey: PANE, + state: 'working', + receivedAt: 1_700_000_000_000, + lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + }) + // The bus validates before delivery; a field the schema strips never reaches a plugin. + expect(agentStatusChangedPayloadSchema.parse(projected)).toEqual(projected) + }) + + it("leaves `lead` absent for a row that carries none, so an old host's rows look as they did", () => { + const projected = projectPluginAgentStatusChangedPayload(row()) + expect(projected).toEqual({ + worktreeId: 'wt-1', + paneKey: PANE, + state: 'working', + receivedAt: 1_700_000_000_000 + }) + expect(projected).not.toHaveProperty('lead') + }) + + it('projects a restored row to nothing, even when its lead reads working', () => { + expect( + projectPluginAgentStatusChangedPayload( + row({ + restoredUnconfirmed: true, + payload: { + state: 'working', + prompt: 'ship it', + agentType: 'claude', + lead: { state: 'working', stateStartedAt: 1 } + } + }) + ) + ).toBeNull() + }) + + it('keeps a missing worktree as null and never invents a verdict on a live lead', () => { + const projected = projectPluginAgentStatusChangedPayload( + row({ + worktreeId: undefined, + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + lead: { state: 'working', stateStartedAt: 7 } + } + }) + ) + expect(projected?.worktreeId).toBeNull() + expect(projected?.lead).toEqual({ state: 'working', stateStartedAt: 7 }) + }) +}) diff --git a/src/main/plugins/plugin-agent-status-event.ts b/src/main/plugins/plugin-agent-status-event.ts new file mode 100644 index 000000000000..49060a25c1ec --- /dev/null +++ b/src/main/plugins/plugin-agent-status-event.ts @@ -0,0 +1,35 @@ +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' +import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/plugin-events' + +/** + * The bounded `agent.status.changed` projection of one status-store row. `state` stays the + * combined status; `lead` rides beside it as the same optional fact the row carries. Restored + * rows project to nothing: plugins may automate on `working`, and a hydrated row is a historical + * claim, not fresh activity — its `lead` no less than its `state`. + */ +export function projectPluginAgentStatusChangedPayload( + enriched: Pick< + EnrichedAgentHookEventPayload, + 'worktreeId' | 'paneKey' | 'receivedAt' | 'restoredUnconfirmed' | 'payload' + > +): PluginAgentStatusChangedPayload | null { + if (enriched.restoredUnconfirmed) { + return null + } + const lead = enriched.payload.lead + return { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + ...(lead + ? { + lead: { + state: lead.state, + ...(lead.outcome ? { outcome: lead.outcome } : {}), + stateStartedAt: lead.stateStartedAt + } + } + : {}) + } +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts index 8c3aeeed10c6..5c34cfbc556d 100644 --- a/src/main/startup/main-process-plugins.ts +++ b/src/main/startup/main-process-plugins.ts @@ -14,6 +14,7 @@ import { normalizePluginConsents, normalizePluginIdList } from '../../shared/plugins/plugin-consent-state' +import { projectPluginAgentStatusChangedPayload } from '../plugins/plugin-agent-status-event' import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' import { rebuildAppMenu } from '../menu/register-app-menu' import { logStartupMilestone } from './startup-diagnostics' @@ -153,16 +154,10 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): // v0 plugin event seams: agent status (hook pipeline tap) + worktree // lifecycle (runtime tap). Server-side filtered per plugin subscription. agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return + const payload = projectPluginAgentStatusChangedPayload(enriched) + if (payload) { + state.pluginService?.emitEvent('agent.status.changed', payload) } - state.pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) }) runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) } diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index bb2fc0c03346..4579c03528a8 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -39,11 +39,28 @@ function hook( paneKey: PANE, connectionId: null, stateStartedAt, + receivedAt: stateStartedAt, payload: { state }, ...extra } } +/** A row from a host that publishes the lead fact beside the combined state. */ +function leadHook( + row: { + state: AgentSessionStatusEvent['payload']['state'] + workingMode?: 'monitoring' + lead: { state: AgentSessionStatusEvent['payload']['state']; stateStartedAt: number } + }, + stateStartedAt: number, + extra: Partial = {} +): AgentSessionStatusEvent { + return hook(row.state, stateStartedAt, { + payload: { state: row.state, workingMode: row.workingMode, lead: row.lead }, + ...extra + }) +} + function sink(): AgentSessionSink & { onAgentStart: Mock onAgentStop: Mock @@ -211,12 +228,137 @@ describe('AgentSessionTransitionRecorder', () => { }) }) +describe('AgentSessionTransitionRecorder reading the lead fact', () => { + // The stats ask "was an agent executing": the lead's own turn, or a subagent still running + // after the lead settled. A background shell the settled lead left behind is neither. + const LEAD_WORKING = { state: 'working' as const, stateStartedAt: T } + + it('stops the session when the lead settles and only a watch loop holds the row working', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + // The Stop hook: the row stays `working` (same clock) in monitoring mode; the lead is done. + recorder.onStatus( + leadHook( + { + state: 'working', + workingMode: 'monitoring', + lead: { state: 'done', stateStartedAt: T + 20_000 } + }, + T, + { receivedAt: T + 20_000 } + ) + ) + // Hours of dev server later, the shell exits and the row settles. + recorder.onStatus( + leadHook( + { state: 'done', lead: { state: 'done', stateStartedAt: T + 20_000 } }, + T + 7_200_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(20_000) + }) + + it('keeps the session open while a subagent outlives the lead, and closes it when the child settles', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + recorder.onStatus( + leadHook({ state: 'working', lead: { state: 'done', stateStartedAt: T + 10_000 } }, T) + ) + recorder.onStatus( + leadHook({ state: 'done', lead: { state: 'done', stateStartedAt: T + 10_000 } }, T + 90_000) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(90_000) + }) + + it('dates the stop by the evidence when a shell outlives the last subagent', () => { + // Lead done at +10s, its subagent finishes at +60s, a shell keeps the row in monitoring: + // neither state clock moves at +60s, so the evidence clock is the edge. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + const leadDone = { state: 'done' as const, stateStartedAt: T + 10_000 } + + recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + recorder.onStatus(leadHook({ state: 'working', lead: leadDone }, T)) + recorder.onStatus( + leadHook({ state: 'working', workingMode: 'monitoring', lead: leadDone }, T, { + receivedAt: T + 60_000 + }) + ) + + expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) + }) + + it('opens a new session dated by the lead clock when the lead resumes after monitoring', () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + recorder.onStatus( + leadHook( + { + state: 'working', + workingMode: 'monitoring', + lead: { state: 'done', stateStartedAt: T + 5_000 } + }, + T, + { receivedAt: T + 5_000 } + ) + ) + // A task notification resumes the lead; the row's own clock never moved off T. + recorder.onStatus( + leadHook({ state: 'working', lead: { state: 'working', stateStartedAt: T + 300_000 } }, T) + ) + recorder.onStatus( + leadHook({ state: 'done', lead: { state: 'done', stateStartedAt: T + 312_000 } }, T + 312_000) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(17_000) + }) + + it('never opens a session from a restored row whose lead reads working', () => { + const restored = sink() + new AgentSessionTransitionRecorder(restored).onStatus( + leadHook({ state: 'working', lead: LEAD_WORKING }, T, { restoredUnconfirmed: true }) + ) + expect(restored.onAgentStart).not.toHaveBeenCalled() + + const replayed = sink() + new AgentSessionTransitionRecorder(replayed).onStatus( + leadHook({ state: 'working', lead: LEAD_WORKING }, T, { isReplay: true }) + ) + expect(replayed.onAgentStart).not.toHaveBeenCalled() + }) + + it("reads an old host's monitoring row exactly as before: working holds the session open", () => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(hook('working', T)) + recorder.onStatus( + hook('working', T, { payload: { state: 'working', workingMode: 'monitoring' } }) + ) + recorder.onStatus(hook('done', T + 40_000)) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(40_000) + }) +}) + describe('classifyAgentSessionTransition', () => { - it('treats an unchanged state as a snapshot, never a transition', () => { + it('treats an unchanged answer as a snapshot, never a transition', () => { expect( - classifyAgentSessionTransition({ state: 'working', open: true }, hook('working', T)) + classifyAgentSessionTransition({ executing: true, open: true }, hook('working', T)) ).toBe('none') - expect(classifyAgentSessionTransition({ state: 'done', open: false }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: false, open: false }, hook('done', T))).toBe( 'none' ) }) @@ -229,10 +371,10 @@ describe('classifyAgentSessionTransition', () => { }) it('closes only a session it opened', () => { - expect(classifyAgentSessionTransition({ state: 'working', open: true }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: true, open: true }, hook('done', T))).toBe( 'stop' ) - expect(classifyAgentSessionTransition({ state: 'working', open: false }, hook('done', T))).toBe( + expect(classifyAgentSessionTransition({ executing: true, open: false }, hook('done', T))).toBe( 'none' ) }) diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index a8bab586eb51..f3047f51a8bc 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -4,7 +4,8 @@ // agent. Hook status is the same truth the sidebar, dashboard, and mobile rows // read, so stats now agree with what the user sees. -import type { AgentStatusState } from '../../shared/agent-status-types' +import { isAgentExecutionOwed } from '../../shared/agent-lead-status-fold' +import type { AgentStatusPayload } from '../../shared/agent-status-types' /** Structural subset of the agent-hook enriched payload this module needs. */ export type AgentSessionStatusEvent = { @@ -17,9 +18,16 @@ export type AgentSessionStatusEvent = { isReplay?: boolean /** Nonterminal state backed only by child state restored from disk. */ restoredUnconfirmed?: true - /** When the current state first appeared; preserved across same-state replays. */ + /** When the current combined state first appeared; preserved across same-state replays. */ stateStartedAt: number - payload: { state: AgentStatusState } + /** When this event arrived; a replay restamps it. */ + receivedAt: number + /** When this evidence was first observed; survives a replay. Absent means `receivedAt`. */ + evidenceObservedAt?: number + /** The combined row state, its watch-loop mode, and the lead's own state when the host is new + * enough to publish one. The stats ask "was an agent executing", which the combined `state` + * alone stopped answering once a settled lead's background shell could hold it `working`. */ + payload: Pick } /** Ordinary pane teardown, or a stamped batch clear for one dropped connection. */ @@ -42,7 +50,8 @@ export type AgentSessionTransition = 'start' | 'stop' | 'none' export const AGENT_SESSION_MIRROR_LIMIT = 1000 type MirroredSession = { - state: AgentStatusState + /** Whether the row last said an agent was executing (the lead or live agent child work). */ + executing: boolean connectionId: string | null /** True while this recorder has an unmatched onAgentStart out to the sink. */ open: boolean @@ -52,35 +61,62 @@ type MirroredSession = { * Pure transition classifier — the whole idempotency contract lives here. * * Rules: - * - Same state as the mirror is a SNAPSHOT, not a transition. Reconnect replay, + * - Same answer as the mirror is a SNAPSHOT, not a transition. Reconnect replay, * disk hydration, and mid-turn tool-progress events all re-emit the current * state; counting those is what makes every reconnect inflate the totals. * - Only a LIVE event may open a session. A replayed or disk-restored `working` * describes work that began in some earlier runtime, so crediting it would * mint a phantom spawn (see #14610: replays carrying an unchanged state used * to re-arm live timing, and cached replays re-fire completion side effects). + * A restored row's `lead` is as historical as its `state`; neither opens. * - Any event may CLOSE a session this recorder opened. A replayed `done` is how * a client learns about a completion it missed while disconnected; refusing it * would strand the session open until the quit flush. */ export function classifyAgentSessionTransition( - previous: Pick | undefined, + previous: Pick | undefined, event: AgentSessionStatusEvent ): AgentSessionTransition { if (event.providerSessionOnly) { return 'none' } - const next = event.payload.state - if (previous && previous.state === next) { + const executing = isAgentExecutionOwed(event.payload) + if (previous && previous.executing === executing) { return 'none' } - if (next === 'working') { + if (executing) { const live = event.isReplay !== true && event.restoredUnconfirmed !== true return live ? 'start' : 'none' } return previous?.open ? 'stop' : 'none' } +/** + * The instant an execution edge happened. Each clock on the row dates one fact, and the edge + * belongs to whichever fact moved: + * - the lead's own turn started or ended: the lead's clock; + * - the row settled or paused with the lead: the row's clock, which moved with it; + * - a settled lead's child work moved the row: the row's clock when the row changed state, and + * the evidence clock when only the watch-loop mode changed (a shell outliving the last + * subagent moves neither state clock). + * A host that publishes no `lead` has only the row's clock, as before. + */ +export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { + const { lead, state } = event.payload + if (!lead) { + return event.stateStartedAt + } + if (lead.state === 'working') { + return lead.stateStartedAt + } + if (state !== 'working') { + return event.stateStartedAt + } + return isAgentExecutionOwed(event.payload) + ? event.stateStartedAt + : (event.evidenceObservedAt ?? event.receivedAt) +} + /** * Mirrors the last hook state per pane and forwards start/stop edges to a sink. * @@ -99,7 +135,8 @@ export class AgentSessionTransitionRecorder { } const previous = this.sessions.get(event.paneKey) const transition = classifyAgentSessionTransition(previous, event) - if (transition === 'none' && previous?.state === event.payload.state) { + const executing = isAgentExecutionOwed(event.payload) + if (transition === 'none' && previous?.executing === executing) { // Refresh recency without touching session state so a long-running pane // isn't evicted ahead of an idle one. this.touch(event.paneKey, previous) @@ -108,15 +145,20 @@ export class AgentSessionTransitionRecorder { let open = previous?.open ?? false if (transition === 'start') { - this.sink.onAgentStart(event.paneKey, event.stateStartedAt, undefined, event.worktreeId) + this.sink.onAgentStart( + event.paneKey, + agentExecutionEdgeAt(event), + undefined, + event.worktreeId + ) open = true } else if (transition === 'stop') { - this.sink.onAgentStop(event.paneKey, event.stateStartedAt) + this.sink.onAgentStop(event.paneKey, agentExecutionEdgeAt(event)) open = false } this.touch(event.paneKey, { - state: event.payload.state, + executing, connectionId: event.connectionId, open }) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index b2ab75e95649..2d52f5f2926b 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -165,3 +165,48 @@ describe('countActivityUnread working turns', () => { expect(countActivityUnread(makeSource({ ...entry, restoredUnconfirmed: true }), 2_000)).toBe(0) }) }) + +describe('countActivityUnread reads the combined state, not the lead fact', () => { + // The badge mirrors the Activity feed, which builds its rows from the combined state: a settled + // lead whose subagent still runs is one live working row there, and no done event exists for + // the lead's own finish until the row itself settles. + const NOW = AGENT_STATUS_STALE_AFTER_MS + + it('counts a fresh row held working by a subagent as one live working turn', () => { + const source = makeSource( + makeEntry({ + state: 'working', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + lead: { state: 'done', stateStartedAt: NOW - 2_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(1) + }) + + it('does not count a settled lead with only a watch loop, matching the feed', () => { + const source = makeSource( + makeEntry({ + state: 'working', + workingMode: 'monitoring', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + lead: { state: 'done', stateStartedAt: NOW - 2_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(0) + }) + + it('does not count a restored row as a live turn, whatever its lead says', () => { + const source = makeSource( + makeEntry({ + state: 'working', + updatedAt: NOW - 1_000, + stateStartedAt: NOW - 5_000, + restoredUnconfirmed: true, + lead: { state: 'working', stateStartedAt: NOW - 5_000 } + }) + ) + expect(countActivityUnread(source, NOW)).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 246670379b4f..97e0b73ff00b 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -63,7 +63,9 @@ function makeEntry(overrides: Partial & { paneKey: string }): stateHistory: overrides.stateHistory ?? [], interrupted: overrides.interrupted, sessionBoundary: overrides.sessionBoundary, - restoredUnconfirmed: overrides.restoredUnconfirmed + restoredUnconfirmed: overrides.restoredUnconfirmed, + workingMode: overrides.workingMode, + lead: overrides.lead } } @@ -783,3 +785,46 @@ describe('buildAttentionByWorktree', () => { expect(map.get(w.id)).toEqual(IDLE) }) }) + +describe('resolveAttention reads the combined state, not the lead fact', () => { + // The classes name what the sidebar row shows (Needs you / Done / Working), so the sort must + // rank by the same combined state the row displays. A settled lead whose subagent still runs + // shows Working; ranking it as Done would file a working row among the finished ones. + const key = paneKey('tab-1', LEAF_1) + + it('ranks a row held working by a subagent as Working, not Done', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + stateStartedAt: NOW - 60_000, + lead: { state: 'done', stateStartedAt: NOW - 30_000 } + }) + expect(resolveAttention(hookPanes([entry]), NOW)).toEqual({ + cls: 3, + attentionTimestamp: NOW - 60_000 + }) + }) + + it('ranks a settled lead with a background watch loop as Working, like the row it shows', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + workingMode: 'monitoring', + stateStartedAt: NOW - 60_000, + lead: { state: 'done', stateStartedAt: NOW - 30_000 } + }) + expect(resolveAttention(hookPanes([entry]), NOW).cls).toBe(3) + }) + + it('never treats a restored row as live, whatever its lead says', () => { + const entry = makeEntry({ + paneKey: key, + state: 'working', + restoredUnconfirmed: true, + lead: { state: 'working', stateStartedAt: NOW - 30_000 } + }) + // Even with a live PTY behind it: a hydrated row has no "how long since we heard" to report. + expect(resolveAttention([hookPane(entry, true)], NOW)).toEqual(IDLE) + expect(resolveAttention([hookPane(entry, false)], NOW)).toEqual(IDLE) + }) +}) diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 252205888f88..f6a6aaf093be 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -3,6 +3,7 @@ import { continueMainAgentStatus, mainAgentTurnInterrupted, foldAgentLeadStatus, + isAgentExecutionOwed, isAgentStatusHeldOpenByChildWork } from './agent-lead-status-fold' @@ -94,6 +95,37 @@ describe('isAgentStatusHeldOpenByChildWork', () => { }) }) +describe('isAgentExecutionOwed', () => { + it('is owed while the main agent itself works, whatever the row shows', () => { + expect(isAgentExecutionOwed({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) + }) + + it('is owed while a settled main agent is held working by live agent child work', () => { + expect(isAgentExecutionOwed({ state: 'working', mainAgent: { state: 'done' } })).toBe(true) + }) + + it('is not owed by a watch loop, a paused main agent, or a settled row', () => { + expect( + isAgentExecutionOwed({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done' } + }) + ).toBe(false) + expect(isAgentExecutionOwed({ state: 'blocked', mainAgent: { state: 'blocked' } })).toBe(false) + // A child's permission prompt while the main agent is settled parks the row; nothing executes. + expect(isAgentExecutionOwed({ state: 'blocked', mainAgent: { state: 'done' } })).toBe(false) + expect(isAgentExecutionOwed({ state: 'done', mainAgent: { state: 'done' } })).toBe(false) + }) + + it("falls back to today's read of the combined state when an old host sends no main agent fact", () => { + expect(isAgentExecutionOwed({ state: 'working' })).toBe(true) + // An old host's monitoring row read as working before `mainAgent` existed; it still does. + expect(isAgentExecutionOwed({ state: 'working', workingMode: 'monitoring' })).toBe(true) + expect(isAgentExecutionOwed({ state: 'done' })).toBe(false) + }) +}) + describe('continueMainAgentStatus', () => { it('keeps the clock across an unchanged state and restarts it on a change', () => { const first = continueMainAgentStatus(undefined, { state: 'working' }, 10) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 416fb23a37a1..e4e66e9ebaa7 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -60,6 +60,30 @@ export function isAgentStatusHeldOpenByChildWork(row: { return row.mainAgent?.state === 'done' && row.state !== 'done' } +/** + * Agent execution is owed: the main agent's own turn runs, or a settled main agent's live agent + * child work still holds the row `working`. A watch loop (`monitoring`) owes nothing — the fold + * reads it that way on purpose — and a main agent paused on a prompt owes nothing even if a child + * runs, matching the combined `state` a reader saw before `mainAgent` existed. Without + * `mainAgent` (an old host) the combined `state` is all there is, and `working` is read exactly + * as it was before. + */ +export function isAgentExecutionOwed(row: { + state: AgentStatusState + workingMode?: AgentWorkingMode + mainAgent?: Pick +}): boolean { + if (!row.mainAgent) { + return row.state === 'working' + } + if (row.mainAgent.state === 'working') { + return true + } + return ( + row.mainAgent.state === 'done' && row.state === 'working' && row.workingMode !== 'monitoring' + ) +} + /** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state * keeps the instant it first appeared, a changed one starts at `now`. A caller that knows * the real instant (a restored stash, a journal record) passes it and wins. */ diff --git a/src/shared/plugins/plugin-events.ts b/src/shared/plugins/plugin-events.ts index 118d0e376cc4..7a2c942ea9cc 100644 --- a/src/shared/plugins/plugin-events.ts +++ b/src/shared/plugins/plugin-events.ts @@ -28,8 +28,21 @@ export const worktreeRemovedPayloadSchema = z.object({ export const agentStatusChangedPayloadSchema = z.object({ worktreeId: z.string().min(1).max(2048).nullable(), paneKey: z.string().min(1).max(2048), + /** The combined status the user sees: `working` while the lead or any live child works. */ state: z.string().min(1).max(256), - receivedAt: z.number().finite().positive() + receivedAt: z.number().finite().positive(), + /** The lead agent's own state beside the combined one, so a plugin can tell "the lead is still + * working" from "a subagent still runs after the lead finished". Absent from hosts that + * predate it and from rows with no lead fact; `state` keeps its meaning either way. */ + lead: z + .object({ + state: z.string().min(1).max(256), + /** The provider's verdict on the lead's last finished turn; present only while `state` is done. */ + outcome: z.string().min(1).max(256).optional(), + // Why: the same bound the row normalizer applies; a stricter one here would reject the whole event. + stateStartedAt: z.number().finite() + }) + .optional() }) export const PLUGIN_EVENT_PAYLOAD_SCHEMAS: Record = { From ac8beb743d2b01c65c0068e22e1d1421164c985f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:29:48 -0700 Subject: [PATCH 2/7] test(agent-status): pin that a child's approval wait no longer splits the recorded span The recorder's move to the lead fact quietly changed one more story: a Codex child's PermissionRequest turns the combined row waiting while the root's own turn keeps running. The old state read closed the span there and minted a second spawn on resume; the new read keeps one span, because the lead never stopped. Pin it at both boundaries (the shared derivation and the recorder) so the change is deliberate, not incidental. --- .../plugins/plugin-agent-status-event.test.ts | 20 ++-- src/main/plugins/plugin-agent-status-event.ts | 16 +-- .../agent-session-transition-recorder.test.ts | 107 ++++++++++++------ .../agent-session-transition-recorder.ts | 26 ++--- .../activity/useActivityUnreadCount.test.ts | 16 +-- .../sidebar/smart-attention.test.ts | 16 +-- src/shared/agent-lead-status-fold.test.ts | 3 + src/shared/plugins/plugin-events.ts | 12 +- 8 files changed, 129 insertions(+), 87 deletions(-) diff --git a/src/main/plugins/plugin-agent-status-event.test.ts b/src/main/plugins/plugin-agent-status-event.test.ts index 06a1c3273b4b..89f7930d65fc 100644 --- a/src/main/plugins/plugin-agent-status-event.test.ts +++ b/src/main/plugins/plugin-agent-status-event.test.ts @@ -17,14 +17,14 @@ function row( } describe('projectPluginAgentStatusChangedPayload', () => { - it('publishes the lead fact beside the combined state, and the schema admits it', () => { + it('publishes the main agent fact beside the combined state, and the schema admits it', () => { const projected = projectPluginAgentStatusChangedPayload( row({ payload: { state: 'working', prompt: 'ship it', agentType: 'claude', - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } } }) ) @@ -33,13 +33,13 @@ describe('projectPluginAgentStatusChangedPayload', () => { paneKey: PANE, state: 'working', receivedAt: 1_700_000_000_000, - lead: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } }) // The bus validates before delivery; a field the schema strips never reaches a plugin. expect(agentStatusChangedPayloadSchema.parse(projected)).toEqual(projected) }) - it("leaves `lead` absent for a row that carries none, so an old host's rows look as they did", () => { + it("leaves `mainAgent` absent for a row that carries none, so an old host's rows look as they did", () => { const projected = projectPluginAgentStatusChangedPayload(row()) expect(projected).toEqual({ worktreeId: 'wt-1', @@ -47,10 +47,10 @@ describe('projectPluginAgentStatusChangedPayload', () => { state: 'working', receivedAt: 1_700_000_000_000 }) - expect(projected).not.toHaveProperty('lead') + expect(projected).not.toHaveProperty('mainAgent') }) - it('projects a restored row to nothing, even when its lead reads working', () => { + it('projects a restored row to nothing, even when its main agent reads working', () => { expect( projectPluginAgentStatusChangedPayload( row({ @@ -59,14 +59,14 @@ describe('projectPluginAgentStatusChangedPayload', () => { state: 'working', prompt: 'ship it', agentType: 'claude', - lead: { state: 'working', stateStartedAt: 1 } + mainAgent: { state: 'working', stateStartedAt: 1 } } }) ) ).toBeNull() }) - it('keeps a missing worktree as null and never invents a verdict on a live lead', () => { + it('keeps a missing worktree as null and never invents a verdict on a live main agent', () => { const projected = projectPluginAgentStatusChangedPayload( row({ worktreeId: undefined, @@ -74,11 +74,11 @@ describe('projectPluginAgentStatusChangedPayload', () => { state: 'working', prompt: '', agentType: 'codex', - lead: { state: 'working', stateStartedAt: 7 } + mainAgent: { state: 'working', stateStartedAt: 7 } } }) ) expect(projected?.worktreeId).toBeNull() - expect(projected?.lead).toEqual({ state: 'working', stateStartedAt: 7 }) + expect(projected?.mainAgent).toEqual({ state: 'working', stateStartedAt: 7 }) }) }) diff --git a/src/main/plugins/plugin-agent-status-event.ts b/src/main/plugins/plugin-agent-status-event.ts index 49060a25c1ec..ae75b1846a38 100644 --- a/src/main/plugins/plugin-agent-status-event.ts +++ b/src/main/plugins/plugin-agent-status-event.ts @@ -3,9 +3,9 @@ import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/plugi /** * The bounded `agent.status.changed` projection of one status-store row. `state` stays the - * combined status; `lead` rides beside it as the same optional fact the row carries. Restored + * combined status; `mainAgent` rides beside it as the same optional fact the row carries. Restored * rows project to nothing: plugins may automate on `working`, and a hydrated row is a historical - * claim, not fresh activity — its `lead` no less than its `state`. + * claim, not fresh activity — its `mainAgent` no less than its `state`. */ export function projectPluginAgentStatusChangedPayload( enriched: Pick< @@ -16,18 +16,18 @@ export function projectPluginAgentStatusChangedPayload( if (enriched.restoredUnconfirmed) { return null } - const lead = enriched.payload.lead + const mainAgent = enriched.payload.mainAgent return { worktreeId: enriched.worktreeId ?? null, paneKey: enriched.paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, - ...(lead + ...(mainAgent ? { - lead: { - state: lead.state, - ...(lead.outcome ? { outcome: lead.outcome } : {}), - stateStartedAt: lead.stateStartedAt + mainAgent: { + state: mainAgent.state, + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt } } : {}) diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index 4579c03528a8..40b4a202fcbf 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -45,18 +45,18 @@ function hook( } } -/** A row from a host that publishes the lead fact beside the combined state. */ -function leadHook( +/** A row from a host that publishes the main agent fact beside the combined state. */ +function mainAgentHook( row: { state: AgentSessionStatusEvent['payload']['state'] workingMode?: 'monitoring' - lead: { state: AgentSessionStatusEvent['payload']['state']; stateStartedAt: number } + mainAgent: { state: AgentSessionStatusEvent['payload']['state']; stateStartedAt: number } }, stateStartedAt: number, extra: Partial = {} ): AgentSessionStatusEvent { return hook(row.state, stateStartedAt, { - payload: { state: row.state, workingMode: row.workingMode, lead: row.lead }, + payload: { state: row.state, workingMode: row.workingMode, mainAgent: row.mainAgent }, ...extra }) } @@ -228,23 +228,23 @@ describe('AgentSessionTransitionRecorder', () => { }) }) -describe('AgentSessionTransitionRecorder reading the lead fact', () => { - // The stats ask "was an agent executing": the lead's own turn, or a subagent still running - // after the lead settled. A background shell the settled lead left behind is neither. - const LEAD_WORKING = { state: 'working' as const, stateStartedAt: T } +describe('AgentSessionTransitionRecorder reading the main agent fact', () => { + // The stats ask "was an agent executing": the main agent's own turn, or a subagent still running + // after the main agent settled. A background shell the settled main agent left behind is neither. + const MAIN_AGENT_WORKING = { state: 'working' as const, stateStartedAt: T } - it('stops the session when the lead settles and only a watch loop holds the row working', () => { + it('stops the session when the main agent settles and only a watch loop holds the row working', () => { const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) - recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) - // The Stop hook: the row stays `working` (same clock) in monitoring mode; the lead is done. + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + // The Stop hook: the row stays `working` (same clock) in monitoring mode; the main agent is done. recorder.onStatus( - leadHook( + mainAgentHook( { state: 'working', workingMode: 'monitoring', - lead: { state: 'done', stateStartedAt: T + 20_000 } + mainAgent: { state: 'done', stateStartedAt: T + 20_000 } }, T, { receivedAt: T + 20_000 } @@ -252,8 +252,8 @@ describe('AgentSessionTransitionRecorder reading the lead fact', () => { ) // Hours of dev server later, the shell exits and the row settles. recorder.onStatus( - leadHook( - { state: 'done', lead: { state: 'done', stateStartedAt: T + 20_000 } }, + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 20_000 } }, T + 7_200_000 ) ) @@ -262,16 +262,22 @@ describe('AgentSessionTransitionRecorder reading the lead fact', () => { expect(stats.getSummary().totalAgentTimeMs).toBe(20_000) }) - it('keeps the session open while a subagent outlives the lead, and closes it when the child settles', () => { + it('keeps the session open while a subagent outlives the main agent, and closes it when the child settles', () => { const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) - recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) recorder.onStatus( - leadHook({ state: 'working', lead: { state: 'done', stateStartedAt: T + 10_000 } }, T) + mainAgentHook( + { state: 'working', mainAgent: { state: 'done', stateStartedAt: T + 10_000 } }, + T + ) ) recorder.onStatus( - leadHook({ state: 'done', lead: { state: 'done', stateStartedAt: T + 10_000 } }, T + 90_000) + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 10_000 } }, + T + 90_000 + ) ) expect(stats.getSummary().totalAgentsSpawned).toBe(1) @@ -279,16 +285,16 @@ describe('AgentSessionTransitionRecorder reading the lead fact', () => { }) it('dates the stop by the evidence when a shell outlives the last subagent', () => { - // Lead done at +10s, its subagent finishes at +60s, a shell keeps the row in monitoring: + // Main agent done at +10s, its subagent finishes at +60s, a shell keeps the row in monitoring: // neither state clock moves at +60s, so the evidence clock is the edge. const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) - const leadDone = { state: 'done' as const, stateStartedAt: T + 10_000 } + const mainAgentDone = { state: 'done' as const, stateStartedAt: T + 10_000 } - recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) - recorder.onStatus(leadHook({ state: 'working', lead: leadDone }, T)) + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: mainAgentDone }, T)) recorder.onStatus( - leadHook({ state: 'working', workingMode: 'monitoring', lead: leadDone }, T, { + mainAgentHook({ state: 'working', workingMode: 'monitoring', mainAgent: mainAgentDone }, T, { receivedAt: T + 60_000 }) ) @@ -296,44 +302,77 @@ describe('AgentSessionTransitionRecorder reading the lead fact', () => { expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) }) - it('opens a new session dated by the lead clock when the lead resumes after monitoring', () => { + it('opens a new session dated by the main agent clock when the main agent resumes after monitoring', () => { const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) - recorder.onStatus(leadHook({ state: 'working', lead: LEAD_WORKING }, T)) + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) recorder.onStatus( - leadHook( + mainAgentHook( { state: 'working', workingMode: 'monitoring', - lead: { state: 'done', stateStartedAt: T + 5_000 } + mainAgent: { state: 'done', stateStartedAt: T + 5_000 } }, T, { receivedAt: T + 5_000 } ) ) - // A task notification resumes the lead; the row's own clock never moved off T. + // A task notification resumes the main agent; the row's own clock never moved off T. recorder.onStatus( - leadHook({ state: 'working', lead: { state: 'working', stateStartedAt: T + 300_000 } }, T) + mainAgentHook( + { state: 'working', mainAgent: { state: 'working', stateStartedAt: T + 300_000 } }, + T + ) ) recorder.onStatus( - leadHook({ state: 'done', lead: { state: 'done', stateStartedAt: T + 312_000 } }, T + 312_000) + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 312_000 } }, + T + 312_000 + ) ) expect(stats.getSummary().totalAgentsSpawned).toBe(2) expect(stats.getSummary().totalAgentTimeMs).toBe(17_000) }) - it('never opens a session from a restored row whose lead reads working', () => { + it("keeps one span across a child's approval wait while the main agent's own turn runs", () => { + // Codex: a child's PermissionRequest turns the combined row `waiting` while the root keeps + // executing. The old state read closed the span there and minted a second spawn on resume; + // the main agent fact says the same turn never stopped. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus( + mainAgentHook({ state: 'waiting', mainAgent: MAIN_AGENT_WORKING }, T + 30_000) + ) + recorder.onStatus( + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 45_000) + ) + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 60_000 } }, + T + 60_000 + ) + ) + + expect(stats.getSummary().totalAgentsSpawned).toBe(1) + expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) + }) + + it('never opens a session from a restored row whose main agent reads working', () => { const restored = sink() new AgentSessionTransitionRecorder(restored).onStatus( - leadHook({ state: 'working', lead: LEAD_WORKING }, T, { restoredUnconfirmed: true }) + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, { + restoredUnconfirmed: true + }) ) expect(restored.onAgentStart).not.toHaveBeenCalled() const replayed = sink() new AgentSessionTransitionRecorder(replayed).onStatus( - leadHook({ state: 'working', lead: LEAD_WORKING }, T, { isReplay: true }) + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, { isReplay: true }) ) expect(replayed.onAgentStart).not.toHaveBeenCalled() }) diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index f3047f51a8bc..58b5a3d9ce2f 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -24,10 +24,10 @@ export type AgentSessionStatusEvent = { receivedAt: number /** When this evidence was first observed; survives a replay. Absent means `receivedAt`. */ evidenceObservedAt?: number - /** The combined row state, its watch-loop mode, and the lead's own state when the host is new + /** The combined row state, its watch-loop mode, and the main agent's own state when the host is new * enough to publish one. The stats ask "was an agent executing", which the combined `state` - * alone stopped answering once a settled lead's background shell could hold it `working`. */ - payload: Pick + * alone stopped answering once a settled main agent's background shell could hold it `working`. */ + payload: Pick } /** Ordinary pane teardown, or a stamped batch clear for one dropped connection. */ @@ -50,7 +50,7 @@ export type AgentSessionTransition = 'start' | 'stop' | 'none' export const AGENT_SESSION_MIRROR_LIMIT = 1000 type MirroredSession = { - /** Whether the row last said an agent was executing (the lead or live agent child work). */ + /** Whether the row last said an agent was executing (the main agent or live agent child work). */ executing: boolean connectionId: string | null /** True while this recorder has an unmatched onAgentStart out to the sink. */ @@ -68,7 +68,7 @@ type MirroredSession = { * describes work that began in some earlier runtime, so crediting it would * mint a phantom spawn (see #14610: replays carrying an unchanged state used * to re-arm live timing, and cached replays re-fire completion side effects). - * A restored row's `lead` is as historical as its `state`; neither opens. + * A restored row's `mainAgent` is as historical as its `state`; neither opens. * - Any event may CLOSE a session this recorder opened. A replayed `done` is how * a client learns about a completion it missed while disconnected; refusing it * would strand the session open until the quit flush. @@ -94,20 +94,20 @@ export function classifyAgentSessionTransition( /** * The instant an execution edge happened. Each clock on the row dates one fact, and the edge * belongs to whichever fact moved: - * - the lead's own turn started or ended: the lead's clock; - * - the row settled or paused with the lead: the row's clock, which moved with it; - * - a settled lead's child work moved the row: the row's clock when the row changed state, and + * - the main agent's own turn started or ended: the main agent's clock; + * - the row settled or paused with the main agent: the row's clock, which moved with it; + * - a settled main agent's child work moved the row: the row's clock when the row changed state, and * the evidence clock when only the watch-loop mode changed (a shell outliving the last * subagent moves neither state clock). - * A host that publishes no `lead` has only the row's clock, as before. + * A host that publishes no `mainAgent` has only the row's clock, as before. */ export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { - const { lead, state } = event.payload - if (!lead) { + const { mainAgent, state } = event.payload + if (!mainAgent) { return event.stateStartedAt } - if (lead.state === 'working') { - return lead.stateStartedAt + if (mainAgent.state === 'working') { + return mainAgent.stateStartedAt } if (state !== 'working') { return event.stateStartedAt diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts index 2d52f5f2926b..51b7027758e1 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -166,10 +166,10 @@ describe('countActivityUnread working turns', () => { }) }) -describe('countActivityUnread reads the combined state, not the lead fact', () => { +describe('countActivityUnread reads the combined state, not the main agent fact', () => { // The badge mirrors the Activity feed, which builds its rows from the combined state: a settled - // lead whose subagent still runs is one live working row there, and no done event exists for - // the lead's own finish until the row itself settles. + // main agent whose subagent still runs is one live working row there, and no done event exists for + // the main agent's own finish until the row itself settles. const NOW = AGENT_STATUS_STALE_AFTER_MS it('counts a fresh row held working by a subagent as one live working turn', () => { @@ -178,33 +178,33 @@ describe('countActivityUnread reads the combined state, not the lead fact', () = state: 'working', updatedAt: NOW - 1_000, stateStartedAt: NOW - 5_000, - lead: { state: 'done', stateStartedAt: NOW - 2_000 } + mainAgent: { state: 'done', stateStartedAt: NOW - 2_000 } }) ) expect(countActivityUnread(source, NOW)).toBe(1) }) - it('does not count a settled lead with only a watch loop, matching the feed', () => { + it('does not count a settled main agent with only a watch loop, matching the feed', () => { const source = makeSource( makeEntry({ state: 'working', workingMode: 'monitoring', updatedAt: NOW - 1_000, stateStartedAt: NOW - 5_000, - lead: { state: 'done', stateStartedAt: NOW - 2_000 } + mainAgent: { state: 'done', stateStartedAt: NOW - 2_000 } }) ) expect(countActivityUnread(source, NOW)).toBe(0) }) - it('does not count a restored row as a live turn, whatever its lead says', () => { + it('does not count a restored row as a live turn, whatever its main agent says', () => { const source = makeSource( makeEntry({ state: 'working', updatedAt: NOW - 1_000, stateStartedAt: NOW - 5_000, restoredUnconfirmed: true, - lead: { state: 'working', stateStartedAt: NOW - 5_000 } + mainAgent: { state: 'working', stateStartedAt: NOW - 5_000 } }) ) expect(countActivityUnread(source, NOW)).toBe(0) diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 97e0b73ff00b..e8bcfad610ab 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -65,7 +65,7 @@ function makeEntry(overrides: Partial & { paneKey: string }): sessionBoundary: overrides.sessionBoundary, restoredUnconfirmed: overrides.restoredUnconfirmed, workingMode: overrides.workingMode, - lead: overrides.lead + mainAgent: overrides.mainAgent } } @@ -786,9 +786,9 @@ describe('buildAttentionByWorktree', () => { }) }) -describe('resolveAttention reads the combined state, not the lead fact', () => { +describe('resolveAttention reads the combined state, not the main agent fact', () => { // The classes name what the sidebar row shows (Needs you / Done / Working), so the sort must - // rank by the same combined state the row displays. A settled lead whose subagent still runs + // rank by the same combined state the row displays. A settled main agent whose subagent still runs // shows Working; ranking it as Done would file a working row among the finished ones. const key = paneKey('tab-1', LEAF_1) @@ -797,7 +797,7 @@ describe('resolveAttention reads the combined state, not the lead fact', () => { paneKey: key, state: 'working', stateStartedAt: NOW - 60_000, - lead: { state: 'done', stateStartedAt: NOW - 30_000 } + mainAgent: { state: 'done', stateStartedAt: NOW - 30_000 } }) expect(resolveAttention(hookPanes([entry]), NOW)).toEqual({ cls: 3, @@ -805,23 +805,23 @@ describe('resolveAttention reads the combined state, not the lead fact', () => { }) }) - it('ranks a settled lead with a background watch loop as Working, like the row it shows', () => { + it('ranks a settled main agent with a background watch loop as Working, like the row it shows', () => { const entry = makeEntry({ paneKey: key, state: 'working', workingMode: 'monitoring', stateStartedAt: NOW - 60_000, - lead: { state: 'done', stateStartedAt: NOW - 30_000 } + mainAgent: { state: 'done', stateStartedAt: NOW - 30_000 } }) expect(resolveAttention(hookPanes([entry]), NOW).cls).toBe(3) }) - it('never treats a restored row as live, whatever its lead says', () => { + it('never treats a restored row as live, whatever its main agent says', () => { const entry = makeEntry({ paneKey: key, state: 'working', restoredUnconfirmed: true, - lead: { state: 'working', stateStartedAt: NOW - 30_000 } + mainAgent: { state: 'working', stateStartedAt: NOW - 30_000 } }) // Even with a live PTY behind it: a hydrated row has no "how long since we heard" to report. expect(resolveAttention([hookPane(entry, true)], NOW)).toEqual(IDLE) diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index f6a6aaf093be..cde6dac132f3 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -98,6 +98,9 @@ describe('isAgentStatusHeldOpenByChildWork', () => { describe('isAgentExecutionOwed', () => { it('is owed while the main agent itself works, whatever the row shows', () => { expect(isAgentExecutionOwed({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) + // Codex: a child's approval prompt turns the combined row `waiting` while the root's own + // turn keeps running; the main agent fact wins over the row it is folded into. + expect(isAgentExecutionOwed({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(true) }) it('is owed while a settled main agent is held working by live agent child work', () => { diff --git a/src/shared/plugins/plugin-events.ts b/src/shared/plugins/plugin-events.ts index 7a2c942ea9cc..320df25c4f77 100644 --- a/src/shared/plugins/plugin-events.ts +++ b/src/shared/plugins/plugin-events.ts @@ -28,16 +28,16 @@ export const worktreeRemovedPayloadSchema = z.object({ export const agentStatusChangedPayloadSchema = z.object({ worktreeId: z.string().min(1).max(2048).nullable(), paneKey: z.string().min(1).max(2048), - /** The combined status the user sees: `working` while the lead or any live child works. */ + /** The combined status the user sees: `working` while the main agent or any live child works. */ state: z.string().min(1).max(256), receivedAt: z.number().finite().positive(), - /** The lead agent's own state beside the combined one, so a plugin can tell "the lead is still - * working" from "a subagent still runs after the lead finished". Absent from hosts that - * predate it and from rows with no lead fact; `state` keeps its meaning either way. */ - lead: z + /** The main agent's own state beside the combined one, so a plugin can tell "the main agent is + * still working" from "a subagent still runs after the main agent finished". Absent from hosts that + * predate it and from rows with no main agent fact; `state` keeps its meaning either way. */ + mainAgent: z .object({ state: z.string().min(1).max(256), - /** The provider's verdict on the lead's last finished turn; present only while `state` is done. */ + /** The provider's verdict on the main agent's last finished turn; present only while `state` is done. */ outcome: z.string().min(1).max(256).optional(), // Why: the same bound the row normalizer applies; a stricter one here would reject the whole event. stateStartedAt: z.number().finite() From 9115cade694971dfa68619d7951d1ea7e649f3fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:16:49 -0700 Subject: [PATCH 3/7] fix(agent-status): date stats edges by this host's clocks and scope the accrual predicate to stats The recorder dated a start by the producer's mainAgent.stateStartedAt. An SSH host stamps that with its own clock while every stop is stamped locally, so each span gained or lost the clock skew. The same clock also survives a row that briefly lost the fact (an OSC repaint to another state), dating the reopen before the close already sent, and a subagent reopening a monitoring row took the row clock the hook lane pins to the main agent's turn start, re-billing the whole watch-loop window. Edges now use the row clock when the row settles or pauses and the evidence clock otherwise. Rename isAgentExecutionOwed to isAgentTimeAccruing and state that it is the stats question, not a liveness gate: it excludes watch loops, which lifecycle gates must keep treating as live. Note on the plugin schema that mainAgent.stateStartedAt is the execution host's clock. --- .../agent-session-transition-recorder.test.ts | 69 ++++++++++++++++++- .../agent-session-transition-recorder.ts | 31 +++------ src/shared/agent-lead-status-fold.test.ts | 30 ++++---- src/shared/agent-lead-status-fold.ts | 13 ++-- src/shared/plugins/plugin-events.ts | 1 + 5 files changed, 99 insertions(+), 45 deletions(-) diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index 40b4a202fcbf..77fea516636c 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -302,7 +302,34 @@ describe('AgentSessionTransitionRecorder reading the main agent fact', () => { expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) }) - it('opens a new session dated by the main agent clock when the main agent resumes after monitoring', () => { + it('dates a subagent reopening a monitoring row by the evidence, not the pinned row clock', () => { + // The hook lane keeps the row's clock across a watch-loop mode change, so it still reads T, + // the start of the main agent's turn; dating the reopen by it would bill the monitoring window. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + const mainAgentDone = { state: 'done' as const, stateStartedAt: T + 5_000 } + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + recorder.onStatus( + mainAgentHook({ state: 'working', workingMode: 'monitoring', mainAgent: mainAgentDone }, T, { + receivedAt: T + 5_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'working', mainAgent: mainAgentDone }, T, { + receivedAt: T + 600_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'done', mainAgent: mainAgentDone }, T + 630_000, { + receivedAt: T + 630_000 + }) + ) + + expect(stats.getSummary().totalAgentTimeMs).toBe(35_000) + }) + + it('opens a new session dated by the observation when the main agent resumes after monitoring', () => { const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) @@ -322,7 +349,8 @@ describe('AgentSessionTransitionRecorder reading the main agent fact', () => { recorder.onStatus( mainAgentHook( { state: 'working', mainAgent: { state: 'working', stateStartedAt: T + 300_000 } }, - T + T, + { receivedAt: T + 300_000 } ) ) recorder.onStatus( @@ -336,6 +364,43 @@ describe('AgentSessionTransitionRecorder reading the main agent fact', () => { expect(stats.getSummary().totalAgentTimeMs).toBe(17_000) }) + it("never dates an edge by an SSH host's main agent clock", () => { + // An SSH host stamps `mainAgent.stateStartedAt` with its own clock (an hour behind here), while + // every other edge is dated by this host; mixing them would add the skew to the span. + const skewed = new StatsCollector() + const remote = new AgentSessionTransitionRecorder(skewed) + remote.onStatus( + mainAgentHook( + { state: 'working', mainAgent: { state: 'working', stateStartedAt: T - 3_600_000 } }, + T + ) + ) + remote.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T - 3_540_000 } }, + T + 60_000 + ) + ) + expect(skewed.getSummary().totalAgentTimeMs).toBe(60_000) + }) + + it('never dates a reopen before the close a fact-less OSC repaint caused', () => { + // An OSC repaint to a different state carries no main agent fact and closes the span; the next + // hook row restores the fact with its unchanged clock, which predates that close. + const osc = new StatsCollector() + const local = new AgentSessionTransitionRecorder(osc) + local.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + local.onStatus(hook('done', T + 10_000)) + local.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 12_000)) + local.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 20_000 } }, + T + 20_000 + ) + ) + expect(osc.getSummary().totalAgentTimeMs).toBe(18_000) + }) + it("keeps one span across a child's approval wait while the main agent's own turn runs", () => { // Codex: a child's PermissionRequest turns the combined row `waiting` while the root keeps // executing. The old state read closed the span there and minted a second spawn on resume; diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index 58b5a3d9ce2f..f64a3a74f37f 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -4,7 +4,7 @@ // agent. Hook status is the same truth the sidebar, dashboard, and mobile rows // read, so stats now agree with what the user sees. -import { isAgentExecutionOwed } from '../../shared/agent-lead-status-fold' +import { isAgentTimeAccruing } from '../../shared/agent-lead-status-fold' import type { AgentStatusPayload } from '../../shared/agent-status-types' /** Structural subset of the agent-hook enriched payload this module needs. */ @@ -80,7 +80,7 @@ export function classifyAgentSessionTransition( if (event.providerSessionOnly) { return 'none' } - const executing = isAgentExecutionOwed(event.payload) + const executing = isAgentTimeAccruing(event.payload) if (previous && previous.executing === executing) { return 'none' } @@ -92,29 +92,18 @@ export function classifyAgentSessionTransition( } /** - * The instant an execution edge happened. Each clock on the row dates one fact, and the edge - * belongs to whichever fact moved: - * - the main agent's own turn started or ended: the main agent's clock; - * - the row settled or paused with the main agent: the row's clock, which moved with it; - * - a settled main agent's child work moved the row: the row's clock when the row changed state, and - * the evidence clock when only the watch-loop mode changed (a shell outliving the last - * subagent moves neither state clock). - * A host that publishes no `mainAgent` has only the row's clock, as before. + * When an execution edge happened, dated only by this host's clocks: the producer's + * `mainAgent.stateStartedAt` is an SSH host's own clock and can predate an edge already sent. + * A row that settled or paused dates the edge by its own clock. A row that stays `working` across + * it does not move that clock (the hook lane pins it across a watch-loop change), so the evidence + * clock dates it. A host that publishes no `mainAgent` has only the row's clock, as before. */ export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { const { mainAgent, state } = event.payload - if (!mainAgent) { + if (!mainAgent || (mainAgent.state !== 'working' && state !== 'working')) { return event.stateStartedAt } - if (mainAgent.state === 'working') { - return mainAgent.stateStartedAt - } - if (state !== 'working') { - return event.stateStartedAt - } - return isAgentExecutionOwed(event.payload) - ? event.stateStartedAt - : (event.evidenceObservedAt ?? event.receivedAt) + return event.evidenceObservedAt ?? event.receivedAt } /** @@ -135,7 +124,7 @@ export class AgentSessionTransitionRecorder { } const previous = this.sessions.get(event.paneKey) const transition = classifyAgentSessionTransition(previous, event) - const executing = isAgentExecutionOwed(event.payload) + const executing = isAgentTimeAccruing(event.payload) if (transition === 'none' && previous?.executing === executing) { // Refresh recency without touching session state so a long-running pane // isn't evicted ahead of an idle one. diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index cde6dac132f3..83832f428ab8 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -3,7 +3,7 @@ import { continueMainAgentStatus, mainAgentTurnInterrupted, foldAgentLeadStatus, - isAgentExecutionOwed, + isAgentTimeAccruing, isAgentStatusHeldOpenByChildWork } from './agent-lead-status-fold' @@ -95,37 +95,37 @@ describe('isAgentStatusHeldOpenByChildWork', () => { }) }) -describe('isAgentExecutionOwed', () => { - it('is owed while the main agent itself works, whatever the row shows', () => { - expect(isAgentExecutionOwed({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) +describe('isAgentTimeAccruing', () => { + it('accrues while the main agent itself works, whatever the row shows', () => { + expect(isAgentTimeAccruing({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) // Codex: a child's approval prompt turns the combined row `waiting` while the root's own // turn keeps running; the main agent fact wins over the row it is folded into. - expect(isAgentExecutionOwed({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(true) + expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(true) }) - it('is owed while a settled main agent is held working by live agent child work', () => { - expect(isAgentExecutionOwed({ state: 'working', mainAgent: { state: 'done' } })).toBe(true) + it('accrues while a settled main agent is held working by live agent child work', () => { + expect(isAgentTimeAccruing({ state: 'working', mainAgent: { state: 'done' } })).toBe(true) }) - it('is not owed by a watch loop, a paused main agent, or a settled row', () => { + it('does not accrue for a watch loop, a paused main agent, or a settled row', () => { expect( - isAgentExecutionOwed({ + isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } }) ).toBe(false) - expect(isAgentExecutionOwed({ state: 'blocked', mainAgent: { state: 'blocked' } })).toBe(false) + expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'blocked' } })).toBe(false) // A child's permission prompt while the main agent is settled parks the row; nothing executes. - expect(isAgentExecutionOwed({ state: 'blocked', mainAgent: { state: 'done' } })).toBe(false) - expect(isAgentExecutionOwed({ state: 'done', mainAgent: { state: 'done' } })).toBe(false) + expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'done' } })).toBe(false) + expect(isAgentTimeAccruing({ state: 'done', mainAgent: { state: 'done' } })).toBe(false) }) it("falls back to today's read of the combined state when an old host sends no main agent fact", () => { - expect(isAgentExecutionOwed({ state: 'working' })).toBe(true) + expect(isAgentTimeAccruing({ state: 'working' })).toBe(true) // An old host's monitoring row read as working before `mainAgent` existed; it still does. - expect(isAgentExecutionOwed({ state: 'working', workingMode: 'monitoring' })).toBe(true) - expect(isAgentExecutionOwed({ state: 'done' })).toBe(false) + expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(true) + expect(isAgentTimeAccruing({ state: 'done' })).toBe(false) }) }) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index e4e66e9ebaa7..45e1e86700e1 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -61,14 +61,13 @@ export function isAgentStatusHeldOpenByChildWork(row: { } /** - * Agent execution is owed: the main agent's own turn runs, or a settled main agent's live agent - * child work still holds the row `working`. A watch loop (`monitoring`) owes nothing — the fold - * reads it that way on purpose — and a main agent paused on a prompt owes nothing even if a child - * runs, matching the combined `state` a reader saw before `mainAgent` existed. Without - * `mainAgent` (an old host) the combined `state` is all there is, and `working` is read exactly - * as it was before. + * The stats question, "does this row accrue agent time": the main agent's own turn runs (a child's + * approval wait included), or a settled main agent's live agent child work still holds the row + * `working`. A watch loop (`monitoring`) accrues nothing, and a main agent paused on a prompt + * accrues nothing even if a child runs. Without `mainAgent` (an old host) `working` is read as before. + * Not a liveness gate: a watch loop is still live work that lifecycle gates must keep honoring. */ -export function isAgentExecutionOwed(row: { +export function isAgentTimeAccruing(row: { state: AgentStatusState workingMode?: AgentWorkingMode mainAgent?: Pick diff --git a/src/shared/plugins/plugin-events.ts b/src/shared/plugins/plugin-events.ts index 320df25c4f77..19537a2c2061 100644 --- a/src/shared/plugins/plugin-events.ts +++ b/src/shared/plugins/plugin-events.ts @@ -40,6 +40,7 @@ export const agentStatusChangedPayloadSchema = z.object({ /** The provider's verdict on the main agent's last finished turn; present only while `state` is done. */ outcome: z.string().min(1).max(256).optional(), // Why: the same bound the row normalizer applies; a stricter one here would reject the whole event. + /** Stamped by the host the agent runs on (an SSH host's own clock), unlike `receivedAt`. */ stateStartedAt: z.number().finite() }) .optional() From 4efbc82dd46d231f2d667e818011e9f535c6a1b8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:59:36 -0700 Subject: [PATCH 4/7] fix(agent-status): pause agent time while the row waits on the user, whoever asked Time agents worked now accrues only while the combined row reads working. A child's approval or question wait pauses the clock exactly like the main agent's own prompt, and the pause edge is dated by the row's own clock. --- .../agent-session-transition-recorder.test.ts | 54 +++++++++++-------- .../agent-session-transition-recorder.ts | 11 ++-- src/shared/agent-lead-status-fold.test.ts | 13 +++-- src/shared/agent-lead-status-fold.ts | 18 +++---- 4 files changed, 55 insertions(+), 41 deletions(-) diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index 77fea516636c..32efc794eeb1 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -401,30 +401,40 @@ describe('AgentSessionTransitionRecorder reading the main agent fact', () => { expect(osc.getSummary().totalAgentTimeMs).toBe(18_000) }) - it("keeps one span across a child's approval wait while the main agent's own turn runs", () => { - // Codex: a child's PermissionRequest turns the combined row `waiting` while the root keeps - // executing. The old state read closed the span there and minted a second spawn on resume; - // the main agent fact says the same turn never stopped. - const stats = new StatsCollector() - const recorder = new AgentSessionTransitionRecorder(stats) - - recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) - recorder.onStatus( - mainAgentHook({ state: 'waiting', mainAgent: MAIN_AGENT_WORKING }, T + 30_000) - ) - recorder.onStatus( - mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 45_000) - ) - recorder.onStatus( - mainAgentHook( - { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 60_000 } }, - T + 60_000 + it.each([ + ['its own prompt', { state: 'waiting' as const, stateStartedAt: T + 30_000 }], + // Codex/Claude: a child's approval prompt parks the row while the displaced main agent reads working. + ["a child's approval prompt", MAIN_AGENT_WORKING] + ])( + 'pauses the clock while the row waits on %s, dated by the row clock', + (_, waitingMainAgent) => { + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T)) + // A reconnect replays the missed wait: the row clock is restamped at the edge, while the + // evidence clock keeps the last observation before the gap. + recorder.onStatus( + mainAgentHook({ state: 'waiting', mainAgent: waitingMainAgent }, T + 30_000, { + isReplay: true, + receivedAt: T + 30_000, + evidenceObservedAt: T + 20_000 + }) + ) + recorder.onStatus( + mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T + 45_000) + ) + recorder.onStatus( + mainAgentHook( + { state: 'done', mainAgent: { state: 'done', stateStartedAt: T + 60_000 } }, + T + 60_000 + ) ) - ) - expect(stats.getSummary().totalAgentsSpawned).toBe(1) - expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) - }) + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(45_000) + } + ) it('never opens a session from a restored row whose main agent reads working', () => { const restored = sink() diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index f64a3a74f37f..cc7362dabba6 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -94,13 +94,14 @@ export function classifyAgentSessionTransition( /** * When an execution edge happened, dated only by this host's clocks: the producer's * `mainAgent.stateStartedAt` is an SSH host's own clock and can predate an edge already sent. - * A row that settled or paused dates the edge by its own clock. A row that stays `working` across - * it does not move that clock (the hook lane pins it across a watch-loop change), so the evidence - * clock dates it. A host that publishes no `mainAgent` has only the row's clock, as before. + * A row that left `working` (settled, or paused on a prompt from the main agent or a child) dates + * the edge by its own clock. A row that is `working` on both sides of the edge does not move that + * clock (the hook lane pins it across a watch-loop change), so the evidence clock dates it; on a + * live hook row that just turned `working` the two agree. A host that publishes no `mainAgent` has + * only the row's clock, as before. */ export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { - const { mainAgent, state } = event.payload - if (!mainAgent || (mainAgent.state !== 'working' && state !== 'working')) { + if (!event.payload.mainAgent || event.payload.state !== 'working') { return event.stateStartedAt } return event.evidenceObservedAt ?? event.receivedAt diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 83832f428ab8..de1cc121ef90 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -96,11 +96,16 @@ describe('isAgentStatusHeldOpenByChildWork', () => { }) describe('isAgentTimeAccruing', () => { - it('accrues while the main agent itself works, whatever the row shows', () => { + it('accrues while the main agent itself works', () => { expect(isAgentTimeAccruing({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) - // Codex: a child's approval prompt turns the combined row `waiting` while the root's own - // turn keeps running; the main agent fact wins over the row it is folded into. - expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(true) + }) + + it("pauses while the row waits on the user, even when the prompt is a child's", () => { + // Codex and Claude: a child's approval or question turns the combined row `waiting`/`blocked` + // while the displaced main agent state still reads working; the row is blocked on the user. + expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(false) + expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'working' } })).toBe(false) + expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'waiting' } })).toBe(false) }) it('accrues while a settled main agent is held working by live agent child work', () => { diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 45e1e86700e1..293013aa2b0c 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -61,10 +61,10 @@ export function isAgentStatusHeldOpenByChildWork(row: { } /** - * The stats question, "does this row accrue agent time": the main agent's own turn runs (a child's - * approval wait included), or a settled main agent's live agent child work still holds the row - * `working`. A watch loop (`monitoring`) accrues nothing, and a main agent paused on a prompt - * accrues nothing even if a child runs. Without `mainAgent` (an old host) `working` is read as before. + * The stats question, "does this row accrue agent time": only a `working` row does, and only while the + * main agent's own turn runs or a settled main agent's live agent child work holds it. A row waiting on + * the user accrues nothing, whoever raised the prompt; a watch loop (`monitoring`) accrues nothing. + * Without `mainAgent` (an old host) `working` is read as before. * Not a liveness gate: a watch loop is still live work that lifecycle gates must keep honoring. */ export function isAgentTimeAccruing(row: { @@ -72,15 +72,13 @@ export function isAgentTimeAccruing(row: { workingMode?: AgentWorkingMode mainAgent?: Pick }): boolean { - if (!row.mainAgent) { - return row.state === 'working' + if (row.state !== 'working') { + return false } - if (row.mainAgent.state === 'working') { + if (!row.mainAgent || row.mainAgent.state === 'working') { return true } - return ( - row.mainAgent.state === 'done' && row.state === 'working' && row.workingMode !== 'monitoring' - ) + return row.mainAgent.state === 'done' && row.workingMode !== 'monitoring' } /** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state From f0295a2665873e91307bd92017165cd5ec0ab0a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:51 -0700 Subject: [PATCH 5/7] refactor(agent-status): read agent time from the combined row and date edges by the row's own state Time agents worked now accrues while the combined row reads working and is not a watch loop. The shared fold emits monitoring only for a settled main agent, and hosts that predate the main agent fact did the same, so this is the same answer on every new-host row without reading mainAgent, and it applies the watch-loop rule to older hosts too instead of billing their monitoring windows. An edge that leaves working is dated by the row's state clock; an edge inside working is dated by the evidence clock. This also stops a live repeat of a hydrated working row (any row without the main agent fact, such as an OSC row) from dating its start at the persisted state clock from the earlier runtime. --- .../agent-session-transition-recorder.test.ts | 79 ++++++++++++++++--- .../agent-session-transition-recorder.ts | 25 +++--- src/shared/agent-lead-status-fold.test.ts | 56 +++++++------ src/shared/agent-lead-status-fold.ts | 17 ++-- 4 files changed, 110 insertions(+), 67 deletions(-) diff --git a/src/main/stats/agent-session-transition-recorder.test.ts b/src/main/stats/agent-session-transition-recorder.test.ts index 32efc794eeb1..74c74273d878 100644 --- a/src/main/stats/agent-session-transition-recorder.test.ts +++ b/src/main/stats/agent-session-transition-recorder.test.ts @@ -10,12 +10,16 @@ import { } from './agent-session-transition-recorder' import type { AgentSessionSink, AgentSessionStatusEvent } from './agent-session-transition-recorder' import { StatsCollector } from './collector' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { PANE as STORE_PANE } from '../agent-hooks/server.test-fixtures' let userDataDir: string vi.mock('electron', () => ({ app: { getPath: () => userDataDir } })) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) const T = 1_700_000_000_000 const PANE = 'tab-1:pane-1' @@ -45,7 +49,8 @@ function hook( } } -/** A row from a host that publishes the main agent fact beside the combined state. */ +/** A row from a host that publishes the main agent fact beside the combined state. The recorder + * reads the combined row only; the fact rides along as it does on the real enriched payload. */ function mainAgentHook( row: { state: AgentSessionStatusEvent['payload']['state'] @@ -55,10 +60,8 @@ function mainAgentHook( stateStartedAt: number, extra: Partial = {} ): AgentSessionStatusEvent { - return hook(row.state, stateStartedAt, { - payload: { state: row.state, workingMode: row.workingMode, mainAgent: row.mainAgent }, - ...extra - }) + const payload = { state: row.state, workingMode: row.workingMode, mainAgent: row.mainAgent } + return hook(row.state, stateStartedAt, { payload, ...extra }) } function sink(): AgentSessionSink & { @@ -228,7 +231,7 @@ describe('AgentSessionTransitionRecorder', () => { }) }) -describe('AgentSessionTransitionRecorder reading the main agent fact', () => { +describe('AgentSessionTransitionRecorder on rows that also carry the main agent fact', () => { // The stats ask "was an agent executing": the main agent's own turn, or a subagent still running // after the main agent settled. A background shell the settled main agent left behind is neither. const MAIN_AGENT_WORKING = { state: 'working' as const, stateStartedAt: T } @@ -452,18 +455,72 @@ describe('AgentSessionTransitionRecorder reading the main agent fact', () => { expect(replayed.onAgentStart).not.toHaveBeenCalled() }) - it("reads an old host's monitoring row exactly as before: working holds the session open", () => { + it("reads an old host's monitoring row like a new host's: the watch loop stops the clock", () => { + // Hosts that predate `mainAgent` already published `monitoring` only for a settled main agent. const stats = new StatsCollector() const recorder = new AgentSessionTransitionRecorder(stats) recorder.onStatus(hook('working', T)) recorder.onStatus( - hook('working', T, { payload: { state: 'working', workingMode: 'monitoring' } }) + hook('working', T, { + receivedAt: T + 20_000, + payload: { state: 'working', workingMode: 'monitoring' } + }) ) - recorder.onStatus(hook('done', T + 40_000)) + // The main agent resumes: the row's clock is still pinned to T. + recorder.onStatus(hook('working', T, { receivedAt: T + 300_000 })) + recorder.onStatus(hook('done', T + 310_000)) - expect(stats.getSummary().totalAgentsSpawned).toBe(1) - expect(stats.getSummary().totalAgentTimeMs).toBe(40_000) + expect(stats.getSummary().totalAgentsSpawned).toBe(2) + expect(stats.getSummary().totalAgentTimeMs).toBe(30_000) + }) + + it('dates the first live row after a restored one by the evidence, not the restored clock', () => { + // A live hook that repeats a hydrated `working` keeps the row clock from the earlier runtime. + const stats = new StatsCollector() + const recorder = new AgentSessionTransitionRecorder(stats) + + recorder.onStatus(hook('working', T - 3_600_000, { receivedAt: T })) + recorder.onStatus(hook('done', T + 60_000)) + + expect(stats.getSummary().totalAgentTimeMs).toBe(60_000) + }) +}) + +describe('AgentSessionTransitionRecorder fed by the status store', () => { + it('dates a live start on a hydrated row by this runtime, not the persisted state clock', async () => { + // An OSC row carries no main agent fact; its live repeat keeps the persisted `stateStartedAt`. + vi.useRealTimers() + _internals.resetCachesForTests() + const hookDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-recorder-hooks-')) + const osc = { + paneKey: STORE_PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working' as const, prompt: 'build', agentType: 'claude' as const } + } + const now = vi.spyOn(Date, 'now').mockReturnValue(T) + const first = new AgentHookServer() + await first.start({ env: 'production', userDataPath: hookDataDir }) + first.ingestTerminalStatus(osc) + first.flushStatusPersistSync() + first.stop() + + now.mockReturnValue(T + 3_600_000) + const server = new AgentHookServer() + const starts = sink() + const recorder = new AgentSessionTransitionRecorder(starts) + try { + await server.start({ env: 'production', userDataPath: hookDataDir }) + server.subscribeEnrichedStatus((enriched) => recorder.onStatus(enriched)) + server.ingestTerminalStatus({ ...osc, payload: { ...osc.payload, toolName: 'Bash' } }) + expect(starts.onAgentStart).toHaveBeenCalledWith(STORE_PANE, T + 3_600_000, undefined, 'wt-1') + } finally { + server.stop() + now.mockRestore() + rmSync(hookDataDir, { recursive: true, force: true }) + } }) }) diff --git a/src/main/stats/agent-session-transition-recorder.ts b/src/main/stats/agent-session-transition-recorder.ts index cc7362dabba6..edf3355bdc07 100644 --- a/src/main/stats/agent-session-transition-recorder.ts +++ b/src/main/stats/agent-session-transition-recorder.ts @@ -24,10 +24,9 @@ export type AgentSessionStatusEvent = { receivedAt: number /** When this evidence was first observed; survives a replay. Absent means `receivedAt`. */ evidenceObservedAt?: number - /** The combined row state, its watch-loop mode, and the main agent's own state when the host is new - * enough to publish one. The stats ask "was an agent executing", which the combined `state` - * alone stopped answering once a settled main agent's background shell could hold it `working`. */ - payload: Pick + /** The combined row state and its watch-loop mode: a settled main agent's background shell holds + * the row `working`, and the stats must not count it. */ + payload: Pick } /** Ordinary pane teardown, or a stamped batch clear for one dropped connection. */ @@ -92,19 +91,15 @@ export function classifyAgentSessionTransition( } /** - * When an execution edge happened, dated only by this host's clocks: the producer's - * `mainAgent.stateStartedAt` is an SSH host's own clock and can predate an edge already sent. - * A row that left `working` (settled, or paused on a prompt from the main agent or a child) dates - * the edge by its own clock. A row that is `working` on both sides of the edge does not move that - * clock (the hook lane pins it across a watch-loop change), so the evidence clock dates it; on a - * live hook row that just turned `working` the two agree. A host that publishes no `mainAgent` has - * only the row's clock, as before. + * When an execution edge happened, on this host's clocks. An edge that leaves `working` is a state + * change, which the row's own clock dates. An edge inside `working` (a watch loop starting or giving + * way to agent work, or the first live row after a restored one) leaves that clock on an older state + * start, so the evidence clock dates it; on a live row that just turned `working` the two agree. */ export function agentExecutionEdgeAt(event: AgentSessionStatusEvent): number { - if (!event.payload.mainAgent || event.payload.state !== 'working') { - return event.stateStartedAt - } - return event.evidenceObservedAt ?? event.receivedAt + return event.payload.state === 'working' + ? (event.evidenceObservedAt ?? event.receivedAt) + : event.stateStartedAt } /** diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index de1cc121ef90..8be2d05959d3 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -96,41 +96,39 @@ describe('isAgentStatusHeldOpenByChildWork', () => { }) describe('isAgentTimeAccruing', () => { - it('accrues while the main agent itself works', () => { - expect(isAgentTimeAccruing({ state: 'working', mainAgent: { state: 'working' } })).toBe(true) - }) - - it("pauses while the row waits on the user, even when the prompt is a child's", () => { - // Codex and Claude: a child's approval or question turns the combined row `waiting`/`blocked` - // while the displaced main agent state still reads working; the row is blocked on the user. - expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'working' } })).toBe(false) - expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'working' } })).toBe(false) - expect(isAgentTimeAccruing({ state: 'waiting', mainAgent: { state: 'waiting' } })).toBe(false) + it('accrues while the row works for the main agent or its live agent child work', () => { + expect(isAgentTimeAccruing({ state: 'working' })).toBe(true) }) - it('accrues while a settled main agent is held working by live agent child work', () => { - expect(isAgentTimeAccruing({ state: 'working', mainAgent: { state: 'done' } })).toBe(true) + it('pauses while the row waits on the user, whoever raised the prompt', () => { + expect(isAgentTimeAccruing({ state: 'waiting' })).toBe(false) + expect(isAgentTimeAccruing({ state: 'blocked' })).toBe(false) }) - it('does not accrue for a watch loop, a paused main agent, or a settled row', () => { - expect( - isAgentTimeAccruing({ - state: 'working', - workingMode: 'monitoring', - mainAgent: { state: 'done' } - }) - ).toBe(false) - expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'blocked' } })).toBe(false) - // A child's permission prompt while the main agent is settled parks the row; nothing executes. - expect(isAgentTimeAccruing({ state: 'blocked', mainAgent: { state: 'done' } })).toBe(false) - expect(isAgentTimeAccruing({ state: 'done', mainAgent: { state: 'done' } })).toBe(false) + it('does not accrue for a watch loop or a settled row', () => { + expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(false) + expect(isAgentTimeAccruing({ state: 'done' })).toBe(false) }) - it("falls back to today's read of the combined state when an old host sends no main agent fact", () => { - expect(isAgentTimeAccruing({ state: 'working' })).toBe(true) - // An old host's monitoring row read as working before `mainAgent` existed; it still does. - expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(true) - expect(isAgentTimeAccruing({ state: 'done' })).toBe(false) + it('relies on the fold emitting monitoring only for a settled main agent', () => { + // Every lane folds through here (Codex never emits monitoring), so a monitoring row can never + // hide a running main agent turn from the stats. + const leadStates = ['working', 'waiting', 'blocked', 'done'] as const + const liveness = ['working', 'monitoring', null] as const + for (const leadState of leadStates) { + for (const interrupted of [false, true]) { + for (const childWorkLiveness of liveness) { + const folded = foldAgentLeadStatus({ leadState, interrupted, childWorkLiveness }) + const accrues = isAgentTimeAccruing({ + state: folded.stateName, + workingMode: folded.workingMode + }) + expect(accrues).toBe( + leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working') + ) + } + } + } }) }) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 293013aa2b0c..f2a38b11b563 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -61,24 +61,17 @@ export function isAgentStatusHeldOpenByChildWork(row: { } /** - * The stats question, "does this row accrue agent time": only a `working` row does, and only while the - * main agent's own turn runs or a settled main agent's live agent child work holds it. A row waiting on - * the user accrues nothing, whoever raised the prompt; a watch loop (`monitoring`) accrues nothing. - * Without `mainAgent` (an old host) `working` is read as before. + * The stats question, "does this row accrue agent time": a `working` row accrues unless it is a watch + * loop. The fold emits `monitoring` only for a settled main agent, so this is the main agent's turn or + * its live agent child work; a row waiting on the user accrues nothing, whoever raised the prompt. + * Reads only the combined row, so an old host that publishes no `mainAgent` is read the same way. * Not a liveness gate: a watch loop is still live work that lifecycle gates must keep honoring. */ export function isAgentTimeAccruing(row: { state: AgentStatusState workingMode?: AgentWorkingMode - mainAgent?: Pick }): boolean { - if (row.state !== 'working') { - return false - } - if (!row.mainAgent || row.mainAgent.state === 'working') { - return true - } - return row.mainAgent.state === 'done' && row.workingMode !== 'monitoring' + return row.state === 'working' && row.workingMode !== 'monitoring' } /** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state From 4c17271ecfb198fc5a5aa5b840e40e9d28b732ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:19:37 -0700 Subject: [PATCH 6/7] fix(grok): read a background subagent as agent work, not a watch loop Grok's end-of-turn Stop lists each in-flight background task with its type (shell, monitor or subagent). Orca filed a running subagent with the shells, so a Grok subagent that outlived the main agent read "Monitoring background tasks" and, with the stats recorder now skipping watch loops, stopped the "Time agents worked" clock. Map shell and subagent entries to the shared child-work kinds and let the shared liveness classifier decide: any live subagent keeps the pane working, a shell alone or an active stop hook stays monitoring, monitors stay excluded. --- .../server-grok-background-status.test.ts | 67 +++++++++++++++++++ ...hook-completion-grok-notifications.test.ts | 35 ++++++++++ ...gent-hook-listener-grok-completion.test.ts | 66 +++++++++++++++++- .../providers/grok-events.ts | 44 ++++++++---- src/shared/main-agent-status-parity.test.ts | 25 ++++--- 5 files changed, 213 insertions(+), 24 deletions(-) diff --git a/src/main/agent-hooks/server-grok-background-status.test.ts b/src/main/agent-hooks/server-grok-background-status.test.ts index 452eb7882b2d..33d2415ebe8e 100644 --- a/src/main/agent-hooks/server-grok-background-status.test.ts +++ b/src/main/agent-hooks/server-grok-background-status.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' import { buildBody, PANE } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ @@ -90,6 +91,72 @@ describe('Grok background status ownership', () => { } }) + it.each([ + { + label: 'a background subagent', + task: { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' }, + workingMode: undefined, + countsAfterMainAgentStops: true + }, + { + label: 'a background shell', + task: { id: 'task-1', type: 'shell', status: 'running', command: 'sleep 30' }, + workingMode: 'monitoring', + countsAfterMainAgentStops: false + } + ])( + 'counts agent time after the main agent stops only for $label', + async ({ task, workingMode, countsAfterMainAgentStops }) => { + const server = new AgentHookServer() + const sink = { onAgentStart: vi.fn(), onAgentStop: vi.fn() } + const recorder = new AgentSessionTransitionRecorder(sink) + await server.start({ env: 'production' }) + server.subscribeEnrichedStatus((enriched) => recorder.onStatus(enriched)) + try { + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + sessionId: 'session-1', + promptId: 'prompt-1', + prompt: 'start background work' + }) + await postGrokHook(server, { + hookEventName: 'stop', + sessionId: 'session-1', + promptId: 'prompt-1', + reason: 'end_turn', + stopHookActive: false, + backgroundTasks: [task] + }) + + expect(sink.onAgentStart).toHaveBeenCalledTimes(1) + expect(sink.onAgentStop).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 0 : 1) + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBe(workingMode) + + await postGrokHook(server, { + hookEventName: 'user_prompt_submit', + sessionId: 'session-1', + promptId: 'task-completed-task-1', + prompt: 'the background task completed' + }) + await postGrokHook(server, { + hookEventName: 'stop', + sessionId: 'session-1', + promptId: 'task-completed-task-1', + reason: 'end_turn', + stopHookActive: false, + backgroundTasks: [] + }) + // The subagent's span stays one session; the shell's wake-up turn is a second one. + expect(sink.onAgentStart).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 1 : 2) + expect(sink.onAgentStop).toHaveBeenCalledTimes(countsAfterMainAgentStops ? 1 : 2) + } finally { + server.stop() + } + } + ) + it('rejects a delayed remote cancellation from the turn replaced by a newer prompt', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts index 24daf5391786..dd7073c3a8c4 100644 --- a/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-grok-notifications.test.ts @@ -242,6 +242,41 @@ describe('Grok hook completion notifications', () => { expect(notifications).toHaveLength(1) }) + it('stays silent while a background subagent outlives the main agent, then announces once', async () => { + const turn = (timestamp: string, promptId: string, backgroundTasks: unknown[]) => [ + { hookEventName: 'UserPromptSubmit', timestamp, sessionId: 'session-1', promptId }, + { + hookEventName: 'Stop', + timestamp: timestamp.replace(':00.000Z', ':01.000Z'), + sessionId: 'session-1', + promptId, + reason: 'end_turn', + stopHookActive: false, + backgroundTasks + } + ] + const early = await play( + turn('2026-09-12T03:05:00.000Z', 'prompt-1', [ + { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' } + ]) + ) + vi.advanceTimersByTime(1_500) + expect(early).toHaveLength(0) + + const notifications = await play([ + ...turn('2026-09-12T03:06:00.000Z', 'prompt-1', [ + { id: 'task-1', type: 'subagent', status: 'running', agentType: 'general-purpose' } + ]), + ...turn('2026-09-12T03:07:00.000Z', 'task-completed-task-1', []) + ]) + vi.advanceTimersByTime(1_500) + expect(notifications).toHaveLength(1) + expect(notifications[0]).toMatchObject({ + at: Date.parse('2026-09-12T03:07:02.500Z'), + snapshot: { state: 'done', agentType: 'grok' } + }) + }) + it('does not announce a delayed cancellation after the next prompt starts', async () => { const notifications = await play([ { diff --git a/src/shared/agent-hook-listener-grok-completion.test.ts b/src/shared/agent-hook-listener-grok-completion.test.ts index e82d1e73900d..c62222510d0d 100644 --- a/src/shared/agent-hook-listener-grok-completion.test.ts +++ b/src/shared/agent-hook-listener-grok-completion.test.ts @@ -212,11 +212,75 @@ describe('Grok completion observations', () => { expect( normalize({ hookEventName: 'Stop', - backgroundTasks: [{ id: 'task-1', type: 'subagent', status: 'starting' }] + backgroundTasks: [{ id: 'task-1', type: 'shell', status: 'starting' }] }) ).toMatchObject({ state: 'working', workingMode: 'monitoring' }) }) + // Real Grok 1.0.41 Stop entry for a background `spawn_subagent`, identifiers dropped. + const backgroundSubagent = { + id: 'task-1', + type: 'subagent', + status: 'running', + agentType: 'general-purpose', + description: 'sleep then reply' + } + const backgroundShell = { id: 'task-2', type: 'shell', status: 'running', command: 'sleep 30' } + + it.each([ + { label: 'alone', backgroundTasks: [backgroundSubagent], stopHookActive: false }, + { + label: 'beside a shell', + backgroundTasks: [backgroundShell, backgroundSubagent], + stopHookActive: false + }, + { + label: 'under an active stop hook', + backgroundTasks: [backgroundSubagent], + stopHookActive: true + } + ])( + 'keeps a background subagent that outlives the main agent working, not monitoring ($label)', + ({ backgroundTasks, stopHookActive }) => { + const row = normalize({ + hookEventName: 'Stop', + reason: 'end_turn', + stopHookActive, + backgroundTasks + }) + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBeUndefined() + } + ) + + it('reads the snake_case inventory the same way', () => { + const row = normalize({ + hookEventName: 'Stop', + reason: 'end_turn', + stop_hook_active: false, + background_tasks: [{ id: 'task-1', type: 'subagent', status: 'running' }] + }) + expect(row).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + expect(row?.workingMode).toBeUndefined() + }) + + it('settles a cancel or a session boundary even while a background subagent runs', () => { + expect( + normalize({ + hookEventName: 'StopCancelled', + reason: 'user_interrupt', + backgroundTasks: [backgroundSubagent] + }) + ).toMatchObject({ state: 'done', interrupted: true }) + expect( + normalize({ + hookEventName: 'Stop', + reason: 'shutdown', + backgroundTasks: [backgroundSubagent] + }) + ).toMatchObject({ state: 'done', sessionBoundary: true }) + }) + it('uses only idle_prompt, not task_complete text, as the session-idle backstop', () => { const taskComplete = normalize( capturedHook((hook) => hook.notificationType === 'task_complete') diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 4b384a173d13..4ba6c58d17ff 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -4,7 +4,12 @@ import { } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' -import type { AgentChildWorkLiveness } from '../../agent-status-child-work-liveness' +import type { AgentChildWorkKind } from '../../agent-status-child-work' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' @@ -92,29 +97,40 @@ function grokTurnEndApplies( ) } -function grokHasRunningFiniteTask(hookPayload: Record): boolean { +/** A finite `backgroundTasks[]` entry as child work. Grok lists only in-flight tasks, so none + * carries a settled state. Monitors are left out: they can run indefinitely and would hold the + * pane (and silence its completion) forever. */ +function grokFiniteTaskKind(task: unknown): AgentChildWorkKind | null { + if (!isRecord(task)) { + return null + } + return task.type === 'subagent' ? 'agent' : task.type === 'shell' ? 'command' : null +} + +function grokRunningFiniteTasks( + hookPayload: Record +): AgentChildWorkLivenessCandidate[] { const backgroundTasks = aliasedField(hookPayload, 'backgroundTasks', 'background_tasks') if (!backgroundTasks.present || !Array.isArray(backgroundTasks.value)) { - return false + return [] } - return backgroundTasks.value.some((task) => { - if (!isRecord(task)) { - return false - } - return task.type === 'shell' || task.type === 'subagent' + return backgroundTasks.value.flatMap((task) => { + const kind = grokFiniteTaskKind(task) + return kind ? [{ kind }] : [] }) } -/** What a plain `stop` leaves running behind the main agent. Grok reports its finite tasks without a - * kind the roster could classify as agent work, and a still-active stop hook holds the turn the - * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ +/** What a plain `stop` leaves running behind the main agent. A background subagent is agent work + * and keeps the pane `working`; a shell, or a still-active stop hook holding the turn, is watch + * work and reads as monitoring. */ function grokChildWorkLivenessAfterStop( hookPayload: Record ): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') - return stopHookActive.value === true || grokHasRunningFiniteTask(hookPayload) - ? 'monitoring' - : null + return ( + agentChildWorkLiveness(grokRunningFiniteTasks(hookPayload)) ?? + (stopHookActive.value === true ? 'monitoring' : null) + ) } function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index c08b5e9f6f7f..35792d210ec0 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -123,9 +123,7 @@ const STORIES: Story[] = [ { hookEventName: 'user_prompt_submit', prompt: 'go' }, { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_AGENT] } ], - // Grok reports no task kind the roster can classify as agent work, so its live subagent - // reads as watch work. Today's label, kept on purpose; a Grok-specific follow-up. - expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + expect: { state: 'working', mainAgent: { state: 'done' } } }, codex: { // A root Stop with no transcript-tracked children clears the roster (Codex 0.144 could omit @@ -434,14 +432,23 @@ describe('mainAgent status parity across lanes', () => { const payload = drive('grok', lane.events) const row = published(payload) expect(row).toEqual(lane.expect) - // Grok's child evidence lives only on its final plain `stop`: a finite task or an active - // stop hook is watch work, and nothing else ever holds the pane. + // Grok's child evidence lives only on its final plain `stop`: a listed subagent is agent + // work, a shell or an active stop hook is watch work, and nothing else ever holds the pane. const last = lane.events.at(-1) ?? {} - const tasks = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const tasks: unknown[] = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const hasType = (type: string) => + tasks.some( + (task) => + typeof task === 'object' && task !== null && 'type' in task && task.type === type + ) const liveness: AgentChildWorkLiveness = - last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) - ? 'monitoring' - : null + last.hookEventName !== 'stop' + ? null + : hasType('subagent') + ? 'working' + : hasType('shell') || last.stopHookActive === true + ? 'monitoring' + : null expect(row).toEqual(refold(row.mainAgent, liveness)) }) }) From 91439ef8e8ce4fbe282ef40a06c16a20639a233b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:30:50 -0700 Subject: [PATCH 7/7] test(agent-status): cover a waiting child in the fold's every-input accrual check Since the shared fold learned a child's human wait, a waiting child makes the row wait, so it must not accrue agent time whatever the main agent is doing. The exhaustive check now includes that input. --- src/shared/agent-lead-status-fold.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 8be2d05959d3..aba8033f45fa 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -114,7 +114,7 @@ describe('isAgentTimeAccruing', () => { // Every lane folds through here (Codex never emits monitoring), so a monitoring row can never // hide a running main agent turn from the stats. const leadStates = ['working', 'waiting', 'blocked', 'done'] as const - const liveness = ['working', 'monitoring', null] as const + const liveness = ['waiting', 'working', 'monitoring', null] as const for (const leadState of leadStates) { for (const interrupted of [false, true]) { for (const childWorkLiveness of liveness) { @@ -123,8 +123,10 @@ describe('isAgentTimeAccruing', () => { state: folded.stateName, workingMode: folded.workingMode }) + // A child waiting on a human pauses the row, whatever the main agent is doing. expect(accrues).toBe( - leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working') + childWorkLiveness !== 'waiting' && + (leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working')) ) } }