mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
fix(agent-status): count only agent work in stats, and read a Grok background subagent as working (#22474)
* 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. * 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. * 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. * 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. * 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. * 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. * 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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<Parameters<typeof projectPluginAgentStatusChangedPayload>[0]> = {}
|
||||
): Parameters<typeof projectPluginAgentStatusChangedPayload>[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 main agent fact beside the combined state, and the schema admits it', () => {
|
||||
const projected = projectPluginAgentStatusChangedPayload(
|
||||
row({
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
agentType: 'claude',
|
||||
mainAgent: { 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,
|
||||
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 `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',
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
receivedAt: 1_700_000_000_000
|
||||
})
|
||||
expect(projected).not.toHaveProperty('mainAgent')
|
||||
})
|
||||
|
||||
it('projects a restored row to nothing, even when its main agent reads working', () => {
|
||||
expect(
|
||||
projectPluginAgentStatusChangedPayload(
|
||||
row({
|
||||
restoredUnconfirmed: true,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
agentType: 'claude',
|
||||
mainAgent: { state: 'working', stateStartedAt: 1 }
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a missing worktree as null and never invents a verdict on a live main agent', () => {
|
||||
const projected = projectPluginAgentStatusChangedPayload(
|
||||
row({
|
||||
worktreeId: undefined,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
mainAgent: { state: 'working', stateStartedAt: 7 }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(projected?.worktreeId).toBeNull()
|
||||
expect(projected?.mainAgent).toEqual({ state: 'working', stateStartedAt: 7 })
|
||||
})
|
||||
})
|
||||
@@ -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; `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 `mainAgent` no less than its `state`.
|
||||
*/
|
||||
export function projectPluginAgentStatusChangedPayload(
|
||||
enriched: Pick<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'worktreeId' | 'paneKey' | 'receivedAt' | 'restoredUnconfirmed' | 'payload'
|
||||
>
|
||||
): PluginAgentStatusChangedPayload | null {
|
||||
if (enriched.restoredUnconfirmed) {
|
||||
return null
|
||||
}
|
||||
const mainAgent = enriched.payload.mainAgent
|
||||
return {
|
||||
worktreeId: enriched.worktreeId ?? null,
|
||||
paneKey: enriched.paneKey,
|
||||
state: enriched.payload.state,
|
||||
receivedAt: enriched.receivedAt,
|
||||
...(mainAgent
|
||||
? {
|
||||
mainAgent: {
|
||||
state: mainAgent.state,
|
||||
...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}),
|
||||
stateStartedAt: mainAgent.stateStartedAt
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -39,11 +43,27 @@ function hook(
|
||||
paneKey: PANE,
|
||||
connectionId: null,
|
||||
stateStartedAt,
|
||||
receivedAt: stateStartedAt,
|
||||
payload: { state },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
/** 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']
|
||||
workingMode?: 'monitoring'
|
||||
mainAgent: { state: AgentSessionStatusEvent['payload']['state']; stateStartedAt: number }
|
||||
},
|
||||
stateStartedAt: number,
|
||||
extra: Partial<AgentSessionStatusEvent> = {}
|
||||
): AgentSessionStatusEvent {
|
||||
const payload = { state: row.state, workingMode: row.workingMode, mainAgent: row.mainAgent }
|
||||
return hook(row.state, stateStartedAt, { payload, ...extra })
|
||||
}
|
||||
|
||||
function sink(): AgentSessionSink & {
|
||||
onAgentStart: Mock<AgentSessionSink['onAgentStart']>
|
||||
onAgentStop: Mock<AgentSessionSink['onAgentStop']>
|
||||
@@ -211,12 +231,305 @@ describe('AgentSessionTransitionRecorder', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
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(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(
|
||||
mainAgentHook(
|
||||
{
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
mainAgent: { 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(
|
||||
mainAgentHook(
|
||||
{ state: 'done', mainAgent: { 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 main agent, and closes it when the child settles', () => {
|
||||
const stats = new StatsCollector()
|
||||
const recorder = new AgentSessionTransitionRecorder(stats)
|
||||
|
||||
recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T))
|
||||
recorder.onStatus(
|
||||
mainAgentHook(
|
||||
{ state: 'working', mainAgent: { state: 'done', stateStartedAt: T + 10_000 } },
|
||||
T
|
||||
)
|
||||
)
|
||||
recorder.onStatus(
|
||||
mainAgentHook(
|
||||
{ state: 'done', mainAgent: { 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', () => {
|
||||
// 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 mainAgentDone = { state: 'done' as const, stateStartedAt: T + 10_000 }
|
||||
|
||||
recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T))
|
||||
recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: mainAgentDone }, T))
|
||||
recorder.onStatus(
|
||||
mainAgentHook({ state: 'working', workingMode: 'monitoring', mainAgent: mainAgentDone }, T, {
|
||||
receivedAt: T + 60_000
|
||||
})
|
||||
)
|
||||
|
||||
expect(stats.getSummary().totalAgentTimeMs).toBe(60_000)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
recorder.onStatus(mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T))
|
||||
recorder.onStatus(
|
||||
mainAgentHook(
|
||||
{
|
||||
state: 'working',
|
||||
workingMode: 'monitoring',
|
||||
mainAgent: { state: 'done', stateStartedAt: T + 5_000 }
|
||||
},
|
||||
T,
|
||||
{ receivedAt: T + 5_000 }
|
||||
)
|
||||
)
|
||||
// A task notification resumes the main agent; the row's own clock never moved off T.
|
||||
recorder.onStatus(
|
||||
mainAgentHook(
|
||||
{ state: 'working', mainAgent: { state: 'working', stateStartedAt: T + 300_000 } },
|
||||
T,
|
||||
{ receivedAt: T + 300_000 }
|
||||
)
|
||||
)
|
||||
recorder.onStatus(
|
||||
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 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.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(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()
|
||||
new AgentSessionTransitionRecorder(restored).onStatus(
|
||||
mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, {
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
)
|
||||
expect(restored.onAgentStart).not.toHaveBeenCalled()
|
||||
|
||||
const replayed = sink()
|
||||
new AgentSessionTransitionRecorder(replayed).onStatus(
|
||||
mainAgentHook({ state: 'working', mainAgent: MAIN_AGENT_WORKING }, T, { isReplay: true })
|
||||
)
|
||||
expect(replayed.onAgentStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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, {
|
||||
receivedAt: T + 20_000,
|
||||
payload: { state: 'working', workingMode: 'monitoring' }
|
||||
})
|
||||
)
|
||||
// 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(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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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 +542,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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 { 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. */
|
||||
export type AgentSessionStatusEvent = {
|
||||
@@ -17,9 +18,15 @@ 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 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<AgentStatusPayload, 'state' | 'workingMode'>
|
||||
}
|
||||
|
||||
/** Ordinary pane teardown, or a stamped batch clear for one dropped connection. */
|
||||
@@ -42,7 +49,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 main agent 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 +60,48 @@ 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 `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.
|
||||
*/
|
||||
export function classifyAgentSessionTransition(
|
||||
previous: Pick<MirroredSession, 'state' | 'open'> | undefined,
|
||||
previous: Pick<MirroredSession, 'executing' | 'open'> | undefined,
|
||||
event: AgentSessionStatusEvent
|
||||
): AgentSessionTransition {
|
||||
if (event.providerSessionOnly) {
|
||||
return 'none'
|
||||
}
|
||||
const next = event.payload.state
|
||||
if (previous && previous.state === next) {
|
||||
const executing = isAgentTimeAccruing(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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return event.payload.state === 'working'
|
||||
? (event.evidenceObservedAt ?? event.receivedAt)
|
||||
: event.stateStartedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the last hook state per pane and forwards start/stop edges to a sink.
|
||||
*
|
||||
@@ -99,7 +120,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 = 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.
|
||||
this.touch(event.paneKey, previous)
|
||||
@@ -108,15 +130,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
|
||||
})
|
||||
|
||||
@@ -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 main agent fact', () => {
|
||||
// The badge mirrors the Activity feed, which builds its rows from the combined state: a settled
|
||||
// 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', () => {
|
||||
const source = makeSource(
|
||||
makeEntry({
|
||||
state: 'working',
|
||||
updatedAt: NOW - 1_000,
|
||||
stateStartedAt: NOW - 5_000,
|
||||
mainAgent: { state: 'done', stateStartedAt: NOW - 2_000 }
|
||||
})
|
||||
)
|
||||
expect(countActivityUnread(source, NOW)).toBe(1)
|
||||
})
|
||||
|
||||
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,
|
||||
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 main agent says', () => {
|
||||
const source = makeSource(
|
||||
makeEntry({
|
||||
state: 'working',
|
||||
updatedAt: NOW - 1_000,
|
||||
stateStartedAt: NOW - 5_000,
|
||||
restoredUnconfirmed: true,
|
||||
mainAgent: { state: 'working', stateStartedAt: NOW - 5_000 }
|
||||
})
|
||||
)
|
||||
expect(countActivityUnread(source, NOW)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,9 @@ function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }):
|
||||
stateHistory: overrides.stateHistory ?? [],
|
||||
interrupted: overrides.interrupted,
|
||||
sessionBoundary: overrides.sessionBoundary,
|
||||
restoredUnconfirmed: overrides.restoredUnconfirmed
|
||||
restoredUnconfirmed: overrides.restoredUnconfirmed,
|
||||
workingMode: overrides.workingMode,
|
||||
mainAgent: overrides.mainAgent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,3 +785,46 @@ describe('buildAttentionByWorktree', () => {
|
||||
expect(map.get(w.id)).toEqual(IDLE)
|
||||
})
|
||||
})
|
||||
|
||||
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 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)
|
||||
|
||||
it('ranks a row held working by a subagent as Working, not Done', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: key,
|
||||
state: 'working',
|
||||
stateStartedAt: NOW - 60_000,
|
||||
mainAgent: { state: 'done', stateStartedAt: NOW - 30_000 }
|
||||
})
|
||||
expect(resolveAttention(hookPanes([entry]), NOW)).toEqual({
|
||||
cls: 3,
|
||||
attentionTimestamp: NOW - 60_000
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
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 main agent says', () => {
|
||||
const entry = makeEntry({
|
||||
paneKey: key,
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
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)
|
||||
expect(resolveAttention([hookPane(entry, false)], NOW)).toEqual(IDLE)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): 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<string, unknown>): boolean {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
continueMainAgentStatus,
|
||||
mainAgentTurnInterrupted,
|
||||
foldAgentLeadStatus,
|
||||
isAgentTimeAccruing,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
} from './agent-lead-status-fold'
|
||||
|
||||
@@ -94,6 +95,45 @@ describe('isAgentStatusHeldOpenByChildWork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAgentTimeAccruing', () => {
|
||||
it('accrues while the row works for the main agent or its live agent child work', () => {
|
||||
expect(isAgentTimeAccruing({ state: 'working' })).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 or a settled row', () => {
|
||||
expect(isAgentTimeAccruing({ state: 'working', workingMode: 'monitoring' })).toBe(false)
|
||||
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 = ['waiting', '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
|
||||
})
|
||||
// A child waiting on a human pauses the row, whatever the main agent is doing.
|
||||
expect(accrues).toBe(
|
||||
childWorkLiveness !== 'waiting' &&
|
||||
(leadState === 'working' || (leadState === 'done' && childWorkLiveness === 'working'))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('continueMainAgentStatus', () => {
|
||||
it('keeps the clock across an unchanged state and restarts it on a change', () => {
|
||||
const first = continueMainAgentStatus(undefined, { state: 'working' }, 10)
|
||||
|
||||
@@ -60,6 +60,20 @@ export function isAgentStatusHeldOpenByChildWork(row: {
|
||||
return row.mainAgent?.state === 'done' && row.state !== 'done'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}): boolean {
|
||||
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
|
||||
* 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. */
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,8 +28,22 @@ 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 main agent or any live child works. */
|
||||
state: z.string().min(1).max(256),
|
||||
receivedAt: z.number().finite().positive()
|
||||
receivedAt: z.number().finite().positive(),
|
||||
/** 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 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()
|
||||
})
|
||||
|
||||
export const PLUGIN_EVENT_PAYLOAD_SCHEMAS: Record<PluginEventName, z.ZodTypeAny> = {
|
||||
|
||||
Reference in New Issue
Block a user