mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(notifications): announce finished only when the agent is fully done (#36992)
This commit is contained in:
@@ -100,8 +100,8 @@ describe('Persisted Claude lead boundaries', () => {
|
||||
lastAssistantMessage: 'Lead finished.',
|
||||
subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })]
|
||||
})
|
||||
const turnCompletedAt = firstServer.getStatusSnapshot()[0]?.turnCompletedAt
|
||||
expect(turnCompletedAt).toEqual(expect.any(Number))
|
||||
// A subagent holds the lead's finish, so its Stop is not stamped for announcement.
|
||||
expect(firstServer.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined()
|
||||
expect(firstServer._getStateForTests().lastStatusByPaneKey.get(PANE)?.hookEventName).toBe(
|
||||
'Stop'
|
||||
)
|
||||
@@ -121,7 +121,7 @@ describe('Persisted Claude lead boundaries', () => {
|
||||
agentType: 'claude',
|
||||
prompt: 'finish after child',
|
||||
lastAssistantMessage: 'Lead finished.',
|
||||
turnCompletedAt
|
||||
mainAgent: expect.objectContaining({ state: 'done' })
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
|
||||
@@ -166,7 +166,7 @@ describe('Persisted Claude lead boundaries', () => {
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'claude',
|
||||
turnCompletedAt: expect.any(Number)
|
||||
mainAgent: expect.objectContaining({ state: 'done' })
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BoundedMap } from '../../shared/bounded-map'
|
||||
import { runsOrcaOrchestrationWait } from '../../shared/orca-cli-invocations'
|
||||
import {
|
||||
claudeRecord,
|
||||
claudeToolUses,
|
||||
readClaudeMessageEnvelope
|
||||
} from './structured/journal/claude-structured-item-translation'
|
||||
|
||||
/** Bash calls that block on `orca orchestration check --wait`, by tool_use_id, until their background task starts. */
|
||||
export class ClaudeAgentWaitCalls {
|
||||
// A foreground wait never starts a task (or is moved to the background later), so bound what waits for one.
|
||||
private readonly pending = new BoundedMap<string, true>({ maxEntries: 256 })
|
||||
|
||||
observe(message: Record<string, unknown>): void {
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
for (const tool of envelope ? claudeToolUses(envelope) : []) {
|
||||
if (runsOrcaOrchestrationWait(claudeRecord(tool.input)?.command)) {
|
||||
this.pending.set(tool.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
take(toolUseId: string | undefined): boolean {
|
||||
return toolUseId !== undefined && this.pending.delete(toolUseId)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,15 @@ describe('a background Stop acts on the host child records', () => {
|
||||
task_id: 'agent-1',
|
||||
patch: { status: 'completed' }
|
||||
}
|
||||
},
|
||||
{
|
||||
at: 7_000,
|
||||
frame: {
|
||||
type: 'system',
|
||||
subtype: 'task_notification',
|
||||
task_id: 'agent-1',
|
||||
status: 'completed'
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-turn-scope'
|
||||
import { isSettledBackgroundTaskState } from '../../shared/native-chat/input/native-chat-background-task-row'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { BoundedMap } from '../../shared/bounded-map'
|
||||
import { record, taskAliasId } from './claude-background-task-frames'
|
||||
import {
|
||||
claudeBackgroundTaskPatchChange,
|
||||
@@ -79,11 +80,29 @@ export class ClaudeBackgroundTaskRows {
|
||||
return { ...this.ledgers.sizes, overflowTerminalRows: this.overflowTerminalRows.size }
|
||||
}
|
||||
|
||||
/** Notification frames that already opened a wake turn; a redelivered one must not open another. */
|
||||
private readonly wakeFrames = new BoundedMap<string, true>({ maxEntries: 64 })
|
||||
|
||||
/** Null outside observe so teardown writes cannot reopen a turn. */
|
||||
private journaling: { frame: Record<string, unknown>; observedAt: number } | null = null
|
||||
|
||||
observe(message: Record<string, unknown>, observedAt: number = this.now()): boolean {
|
||||
this.journaling = { frame: message, observedAt }
|
||||
// Claude wakes the lead for any task the lead launched, rowless subagents included; open that turn at the notification, once per frame.
|
||||
const toolUseId =
|
||||
message.subtype === 'task_notification' ? taskAliasId(message.tool_use_id) : undefined
|
||||
const frameId = typeof message.uuid === 'string' ? message.uuid : undefined
|
||||
if (
|
||||
message.type === 'system' &&
|
||||
toolUseId &&
|
||||
this.deps.isForwardedParentTool(toolUseId) &&
|
||||
!(frameId && this.wakeFrames.has(frameId))
|
||||
) {
|
||||
if (frameId) {
|
||||
this.wakeFrames.set(frameId, true)
|
||||
}
|
||||
this.deps.openOutputTurn?.(message, observedAt)
|
||||
}
|
||||
try {
|
||||
return this.observeFrame(message)
|
||||
} finally {
|
||||
|
||||
@@ -53,6 +53,48 @@ describe('Claude child-work decoder', () => {
|
||||
expect(decoder.drain(600)).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a background orca orchestration check --wait as the worker wait it is', () => {
|
||||
const decoder = new ClaudeChildWorkDecoder()
|
||||
decoder.observe({
|
||||
type: 'assistant',
|
||||
session_id: 'provider-1',
|
||||
uuid: 'assistant-1',
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_wait',
|
||||
name: 'Bash',
|
||||
input: {
|
||||
command: `ORCA_RUN=${'x'.repeat(200)} orca orchestration check --wait --json`,
|
||||
run_in_background: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
decoder.observe(
|
||||
system('task_started', {
|
||||
task_id: 'wait-1',
|
||||
tool_use_id: 'toolu_wait',
|
||||
task_type: 'local_bash',
|
||||
description: 'Wait for next worker message',
|
||||
is_backgrounded: true
|
||||
})
|
||||
)
|
||||
expect(decoder.drain(500)).toMatchObject([
|
||||
{
|
||||
type: 'live',
|
||||
child: {
|
||||
kind: 'command',
|
||||
operation: { toolName: 'Bash', input: 'orca orchestration check --wait' }
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it("carries a child's progress: the tool it last ran, its summary and its usage", () => {
|
||||
const decoder = decoderWith(foregroundAgent)
|
||||
decoder.observe(
|
||||
@@ -117,6 +159,16 @@ describe('Claude child-work decoder', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('ends a background agent at the notification that wakes its launcher, not the update before it', () => {
|
||||
for (const status of ['completed', 'failed', 'killed']) {
|
||||
const decoder = decoderWith(backgroundAgent)
|
||||
decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { status } }))
|
||||
expect(decoder.drain(500)).toEqual([])
|
||||
decoder.observe(system('task_notification', { task_id: 'agent-bg', status }))
|
||||
expect(decoder.drain(600)).toEqual([expect.objectContaining({ type: 'ended' })])
|
||||
}
|
||||
})
|
||||
|
||||
it("reads nothing from a roster, a turn's end or a spawn call's result", () => {
|
||||
const decoder = decoderWith(foregroundAgent, backgroundAgent)
|
||||
decoder.observe(system('background_tasks_changed', { tasks: [] }))
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
terminalClaudeTaskRunState
|
||||
} from './claude-background-task-frames'
|
||||
import { isClaudeSubagentTask } from './claude-subagent-task-frames'
|
||||
import { ClaudeAgentWaitCalls } from './claude-agent-wait-calls'
|
||||
import { ORCA_ORCHESTRATION_WAIT_COMMAND } from '../../shared/orca-cli-invocations'
|
||||
import {
|
||||
claudeChildWorkOutcome,
|
||||
claudeTaskProgressFacts,
|
||||
@@ -36,8 +38,12 @@ type DecodedClaudeTask = {
|
||||
name?: string
|
||||
description?: string
|
||||
toolUseId?: string
|
||||
/** Runs `orca orchestration check --wait`: waiting on worker agents, so agent work. */
|
||||
awaitsAgents?: true
|
||||
}
|
||||
|
||||
type ClaudeToolOwner = (toolUseId: string) => string | null
|
||||
|
||||
type PendingEdge = (observedAt: number) => AgentChildWorkEvidence
|
||||
|
||||
function observation(
|
||||
@@ -49,7 +55,14 @@ function observation(
|
||||
): AgentChildWorkLiveObservation {
|
||||
const operation: AgentChildWorkOperation | undefined = facts.toolName
|
||||
? { toolName: facts.toolName, basis: 'reported', observedAt }
|
||||
: undefined
|
||||
: task.awaitsAgents
|
||||
? {
|
||||
toolName: 'Bash',
|
||||
input: ORCA_ORCHESTRATION_WAIT_COMMAND,
|
||||
basis: 'reported',
|
||||
observedAt
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
handle: {
|
||||
idKind: 'task_id',
|
||||
@@ -74,12 +87,21 @@ export class ClaudeChildWorkDecoder {
|
||||
/** Retain ended runs for the session to distinguish wakes from late starts. */
|
||||
private readonly ended = new Map<
|
||||
string,
|
||||
{ run: string | undefined; outcome: AgentChildWorkOutcome }
|
||||
{
|
||||
run: string | undefined
|
||||
outcome: AgentChildWorkOutcome
|
||||
task?: DecodedClaudeTask
|
||||
}
|
||||
>()
|
||||
private waiting: ReadonlySet<string> = new Set()
|
||||
private pending: PendingEdge[] = []
|
||||
|
||||
observe(message: Record<string, unknown>): void {
|
||||
private readonly agentWaits = new ClaudeAgentWaitCalls()
|
||||
/** `ownerOf` names the subagent that made a child tool call; an ending task wakes it. */
|
||||
observe(message: Record<string, unknown>, ownerOf?: ClaudeToolOwner): void {
|
||||
if (message.type === 'assistant') {
|
||||
this.agentWaits.observe(message)
|
||||
return
|
||||
}
|
||||
if (message.type !== 'system') {
|
||||
return
|
||||
}
|
||||
@@ -95,14 +117,19 @@ export class ClaudeChildWorkDecoder {
|
||||
this.progressed(id, message)
|
||||
return
|
||||
case 'task_updated':
|
||||
this.updated(id, record(message.patch))
|
||||
this.updated(id, record(message.patch), ownerOf)
|
||||
return
|
||||
case 'task_notification':
|
||||
this.end(id, message.status, {
|
||||
runId: taskAliasId(message.tool_use_id),
|
||||
lastMessage: taskText(message.summary),
|
||||
totalTokens: taskUsageTotalTokens(message)
|
||||
})
|
||||
this.end(
|
||||
id,
|
||||
message.status,
|
||||
{
|
||||
runId: taskAliasId(message.tool_use_id),
|
||||
lastMessage: taskText(message.summary),
|
||||
totalTokens: taskUsageTotalTokens(message)
|
||||
},
|
||||
ownerOf
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +147,7 @@ export class ClaudeChildWorkDecoder {
|
||||
clear(): void {
|
||||
this.live.clear()
|
||||
this.ended.clear()
|
||||
this.agentWaits.clear()
|
||||
this.waiting = new Set()
|
||||
this.pending.push((observedAt) => ({ type: 'session-ended', observedAt }))
|
||||
}
|
||||
@@ -131,10 +159,11 @@ export class ClaudeChildWorkDecoder {
|
||||
}
|
||||
|
||||
private started(id: string, message: Record<string, unknown>): void {
|
||||
const toolUseId = taskAliasId(message.tool_use_id)
|
||||
const awaitsAgents = this.agentWaits.take(toolUseId)
|
||||
if (message.ambient === true || message.skip_transcript === true) {
|
||||
return
|
||||
}
|
||||
const toolUseId = taskAliasId(message.tool_use_id)
|
||||
const existing = this.live.get(id)
|
||||
const ended = existing ? undefined : this.ended.get(id)
|
||||
const restart = ended !== undefined
|
||||
@@ -155,13 +184,23 @@ export class ClaudeChildWorkDecoder {
|
||||
running: liveClaudeTaskRunState(message.status) !== null || existing?.running === true,
|
||||
name: taskName(message) ?? existing?.name,
|
||||
description: taskDescription(message.description) ?? existing?.description,
|
||||
toolUseId: toolUseId ?? existing?.toolUseId
|
||||
toolUseId: toolUseId ?? existing?.toolUseId,
|
||||
...(awaitsAgents || existing?.awaitsAgents ? { awaitsAgents: true as const } : {})
|
||||
},
|
||||
{},
|
||||
restart
|
||||
)
|
||||
}
|
||||
|
||||
/** Claude resumes a finished subagent when a task it launched notifies; show it working from that frame, not its restart a moment later. */
|
||||
private wakeOwner(toolUseId: string | undefined, ownerOf: ClaudeToolOwner | undefined): void {
|
||||
const ownerId = toolUseId ? ownerOf?.(toolUseId) : null
|
||||
const owner = ownerId ? this.ended.get(ownerId) : undefined
|
||||
if (ownerId && owner?.outcome === 'succeeded' && owner.task?.kind === 'agent') {
|
||||
this.upsert(ownerId, { ...owner.task, running: true }, {}, true)
|
||||
}
|
||||
}
|
||||
|
||||
private progressed(id: string, message: Record<string, unknown>): void {
|
||||
const task = this.live.get(id)
|
||||
if (!task) {
|
||||
@@ -175,15 +214,22 @@ export class ClaudeChildWorkDecoder {
|
||||
)
|
||||
}
|
||||
|
||||
private updated(id: string, patch: Record<string, unknown> | null): void {
|
||||
private updated(
|
||||
id: string,
|
||||
patch: Record<string, unknown> | null,
|
||||
ownerOf: ClaudeToolOwner | undefined
|
||||
): void {
|
||||
if (!patch) {
|
||||
return
|
||||
}
|
||||
const existing = this.live.get(id)
|
||||
if (terminalClaudeTaskRunState(patch.status) !== null) {
|
||||
this.end(id, patch.status, { lastMessage: taskText(patch.error) })
|
||||
// A background agent's ending always notifies its launcher at once; ending here would show the launcher finished before that wake.
|
||||
if (existing?.backgrounded !== true || existing.kind !== 'agent') {
|
||||
this.end(id, patch.status, { lastMessage: taskText(patch.error) }, ownerOf)
|
||||
}
|
||||
return
|
||||
}
|
||||
const existing = this.live.get(id)
|
||||
// Late updates cannot reopen an ended task.
|
||||
if (!existing && this.ended.has(id)) {
|
||||
return
|
||||
@@ -204,7 +250,8 @@ export class ClaudeChildWorkDecoder {
|
||||
running: running || existing?.running === true,
|
||||
name: name ?? existing?.name,
|
||||
description: description ?? existing?.description,
|
||||
toolUseId: existing?.toolUseId
|
||||
toolUseId: existing?.toolUseId,
|
||||
...(existing?.awaitsAgents ? { awaitsAgents: true as const } : {})
|
||||
},
|
||||
{},
|
||||
false
|
||||
@@ -239,12 +286,22 @@ export class ClaudeChildWorkDecoder {
|
||||
runId?: string
|
||||
lastMessage?: string
|
||||
totalTokens?: number
|
||||
}
|
||||
},
|
||||
ownerOf?: ClaudeToolOwner
|
||||
): void {
|
||||
const task = this.live.get(id)
|
||||
this.live.delete(id)
|
||||
const outcome = claudeChildWorkOutcome(status)
|
||||
this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome })
|
||||
this.ended.set(id, {
|
||||
run: reported.runId ?? task?.toolUseId,
|
||||
outcome,
|
||||
// Kept only for an agent a task it launched can wake.
|
||||
...(task?.kind === 'agent' ? { task } : {})
|
||||
})
|
||||
// Every ending but a stop notifies, which wakes the subagent that launched the task.
|
||||
if (outcome !== 'cancelled') {
|
||||
this.wakeOwner(reported.runId ?? task?.toolUseId, ownerOf)
|
||||
}
|
||||
this.pending.push((observedAt) => ({
|
||||
type: 'ended',
|
||||
observedAt,
|
||||
|
||||
@@ -26,7 +26,7 @@ export function emitClaudeStructuredSessionEvent({
|
||||
session?.childWork.clear()
|
||||
session?.backgroundTasks.clear()
|
||||
} else if (event.type === 'message') {
|
||||
session?.childWork.observe(event.message)
|
||||
session?.childWork.observe(event.message, session.translator?.childToolOwner)
|
||||
session?.backgroundTasks.observe(event.message, event.startsTurn === true)
|
||||
} else if (event.type === 'prompt-cancelled') {
|
||||
// Clear waiting before journaling cancellation so status cannot wait on a closed card.
|
||||
|
||||
@@ -98,7 +98,8 @@ export class ConversationEvents {
|
||||
deliver: (item) => this.told(item),
|
||||
setTimer: (run, delayMs) => this.deps.setTimer(run, delayMs),
|
||||
started: (item) => this.started(item),
|
||||
stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row))
|
||||
stopped: (item) => this.ended(item, agentMainAgentVerdict(item.row)),
|
||||
everyLeadTurn: true
|
||||
})
|
||||
const unsubscribe = this.deps.onAgentPaneChange((change) => {
|
||||
this.settleRejected(change.paneKey)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload'
|
||||
import type { AgentPhase, AgentTransition } from '../../shared/agent-transitions'
|
||||
import {
|
||||
AGENT_DONE_QUIET_MS,
|
||||
type AgentPhase,
|
||||
type AgentTransition
|
||||
} from '../../shared/agent-transitions'
|
||||
import type { ConversationEvent, ConversationSnapshot } from './external-conversation-events'
|
||||
import type { ConversationKey } from './external-conversation-types'
|
||||
import {
|
||||
@@ -223,6 +227,56 @@ describe('ExternalConversationSeam watch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tells the reply of a turn its subagent holds open, then the wake-up turn that follows', async () => {
|
||||
const { seam, host, hookRows, paneChange } = await linked()
|
||||
const seen: ConversationEvent[] = []
|
||||
seam.watch(host, (event) => {
|
||||
if (event.kind !== 'snapshot') {
|
||||
seen.push(event)
|
||||
}
|
||||
})
|
||||
const observedWorking: AgentPaneChange = {
|
||||
kind: 'observed',
|
||||
paneKey: PANE,
|
||||
workspace: { worktreeId: WORKTREE, sshHostId: null },
|
||||
phase: 'working',
|
||||
at: 2
|
||||
}
|
||||
hookRows.set(PANE, hookRow({ turnStartedAt: 100 }))
|
||||
paneChange(moved('rest', 'working', 'prompt-submitted'))
|
||||
paneChange(observedWorking)
|
||||
// The lead's turn ended; its running subagent keeps the row working and holds the notification.
|
||||
const launched = { state: 'done' as const, stateStartedAt: 300, outcome: 'success' as const }
|
||||
hookRows.set(
|
||||
PANE,
|
||||
hookRow({ turnStartedAt: 100, lastAssistantMessage: 'Launched.', mainAgent: launched })
|
||||
)
|
||||
paneChange(observedWorking)
|
||||
hookRows.set(PANE, hookRow({ turnStartedAt: 400 }))
|
||||
paneChange(observedWorking)
|
||||
hookRows.set(
|
||||
PANE,
|
||||
hookRow({
|
||||
state: 'done',
|
||||
stateStartedAt: 500,
|
||||
turnStartedAt: 400,
|
||||
lastAssistantMessage: 'Subagent done.',
|
||||
mainAgent: { state: 'done', stateStartedAt: 500, outcome: 'success' }
|
||||
})
|
||||
)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
paneChange(moved('working', 'done', 'turn-finished'))
|
||||
vi.advanceTimersByTime(AGENT_DONE_QUIET_MS)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
expect(
|
||||
seen.flatMap((event) => (event.kind === 'turn-ended' ? [event.lastAssistantMessage] : []))
|
||||
).toEqual(['Launched.', 'Subagent done.'])
|
||||
})
|
||||
|
||||
it('names the turn a reply belongs to as turn events do, only while watched and linked', async () => {
|
||||
const { seam, host, hookRows, paneChange } = await linked()
|
||||
expect(seam.replyTarget(PANE)).toBeNull()
|
||||
|
||||
+43
-1
@@ -10,6 +10,11 @@ import { isSubagentBlock } from '../../../shared/native-chat/native-chat-types'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/agent-session/view/structured-agent-session-live-turn'
|
||||
import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support'
|
||||
import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture'
|
||||
import {
|
||||
result,
|
||||
says,
|
||||
type CapturedFrame
|
||||
} from '../../claude/claude-captured-frame-builders.test-fixture'
|
||||
import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture'
|
||||
import { ClaudeStructuredSessionAdapter } from '../../claude/structured/claude-structured-session-adapter'
|
||||
import {
|
||||
@@ -213,6 +218,42 @@ it("keeps an agent whose helper the store refused past idle cleanup, until the s
|
||||
await eventually(() => expect(strip()?.incomplete).toBeUndefined())
|
||||
})
|
||||
|
||||
it('announces a helper that outlives the turn once, after the lead answers its final result', async () => {
|
||||
const connection = claude.connections[0]!
|
||||
const completions: string[] = []
|
||||
host.subscribeTurnCompletions({
|
||||
id: 'completions',
|
||||
emit: (event) => {
|
||||
if (event.type === 'completion') {
|
||||
completions.push(event.completion.turnId)
|
||||
}
|
||||
}
|
||||
})
|
||||
await sendAndEcho(connection, 'Sleep in the background.')
|
||||
const start = clock
|
||||
// The capture omits the lead's replies: Claude wakes it after each of the helper's notifications.
|
||||
const leadWake = (at: number, uuid: string): CapturedFrame[] => [
|
||||
{ at, frame: says(uuid, 'Noted.', null) },
|
||||
{ at: at + 1, frame: result('success') }
|
||||
]
|
||||
const frames: CapturedFrame[] = WAKES_AFTER_ITS_SHELL.flatMap((captured, index) =>
|
||||
captured.frame.subtype === 'task_notification' && captured.frame.task_id === 'agent-1'
|
||||
? [captured, ...leadWake(captured.at + 1_000, `lead-wake-${index}`)]
|
||||
: [captured]
|
||||
)
|
||||
for (const [index, captured] of frames.entries()) {
|
||||
clock = start + captured.at
|
||||
frame(connection, { uuid: `frame-${index}`, ...captured.frame })
|
||||
await host.flushStreamedEvents(SESSION)
|
||||
}
|
||||
|
||||
// The first turn and the wake while the helper's own shell runs stay quiet; the wake its final notification opens announces.
|
||||
const finalNotification = frames.findLastIndex(
|
||||
({ frame }) => frame.subtype === 'task_notification'
|
||||
)
|
||||
await eventually(() => expect(completions).toEqual([`frame-${finalNotification}`]))
|
||||
})
|
||||
|
||||
it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => {
|
||||
const connection = claude.connections[0]!
|
||||
await sendAndEcho(connection, 'Sleep in the background.')
|
||||
@@ -246,7 +287,8 @@ it('keeps a helper working while its own shell runs and through its wake-up, end
|
||||
state: 'completed',
|
||||
tokens: 18_181,
|
||||
startedAt: start + 15,
|
||||
settledAt: start + 46_198
|
||||
// Settled by its notification, which wakes the lead, not the update a millisecond before.
|
||||
settledAt: start + 46_199
|
||||
})
|
||||
)
|
||||
expect(strip()).toEqual([])
|
||||
|
||||
@@ -78,7 +78,8 @@ export class StructuredAgentSessionClientDelivery {
|
||||
sessions,
|
||||
now,
|
||||
readStatusState: (sessionId, journal) =>
|
||||
this.statusFeed.journalProjection(sessionId, journal)?.state ?? null
|
||||
this.statusFeed.journalProjection(sessionId, journal)?.state ?? null,
|
||||
readChildWork: (sessionId) => this.statusFeed.readChildWork(sessionId)
|
||||
})
|
||||
this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) =>
|
||||
this.settlementReading(sessionId, this.requireJournal(sessionId))
|
||||
@@ -173,7 +174,11 @@ export class StructuredAgentSessionClientDelivery {
|
||||
sessionId: string,
|
||||
evidence: AgentChildWorkEvidence[],
|
||||
producer?: StructuredAgentSessionChildWorkProducer
|
||||
): void => this.statusFeed.publishChildWork(sessionId, evidence, producer)
|
||||
): void => {
|
||||
this.statusFeed.publishChildWork(sessionId, evidence, producer)
|
||||
// A drain can be the moment a held turn becomes finished.
|
||||
this.turnCompletionFeed.observe(sessionId)
|
||||
}
|
||||
|
||||
readStopping = (sessionId: string): boolean => {
|
||||
const projection = this.statusFeed.journalProjection(sessionId)
|
||||
|
||||
+3
-1
@@ -92,7 +92,9 @@ async function sessionWithRunningTurn() {
|
||||
const completions = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now: () => clock,
|
||||
readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null
|
||||
readStatusState: (sessionId, source) =>
|
||||
feed.journalProjection(sessionId, source)?.state ?? null,
|
||||
readChildWork: (sessionId) => feed.readChildWork(sessionId)
|
||||
})
|
||||
const completionEvents: AgentSessionTurnCompletionEvent[] = []
|
||||
completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) })
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission,
|
||||
AgentJournalTurnLifecycle
|
||||
} from '../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import type { AgentChildWorkLivenessCandidate } from '../../../shared/agent-status-child-work-liveness'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed'
|
||||
|
||||
const LOCATION = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'git-worktree'
|
||||
} as const
|
||||
const SUBAGENT = { kind: 'agent', state: 'working' } as const
|
||||
const DEV_SERVER = { kind: 'command', state: 'working' } as const
|
||||
|
||||
function turnItem(lifecycle: AgentJournalTurnLifecycle, sequence = 2): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `codex:turn:${lifecycle.turnId}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'turn', ...lifecycle }
|
||||
}
|
||||
}
|
||||
|
||||
// A turn the agent opened itself keys its lifecycle row to itself.
|
||||
function selfStarted(lifecycle: AgentJournalTurnLifecycle): AgentJournalTurnLifecycle {
|
||||
return { ...lifecycle, userItemId: `codex:turn:${lifecycle.turnId}` }
|
||||
}
|
||||
|
||||
function harness(childWork: AgentChildWorkLivenessCandidate[]) {
|
||||
let items: AgentJournalRenderItem[] = []
|
||||
let submissions: AgentJournalSubmission[] = []
|
||||
let sequence = 0
|
||||
const journal = { cursor: () => ({ epoch: 'epoch-1', sequence }) }
|
||||
const feed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions: new Map([['session-1', { journal, params: { location: LOCATION } }]]),
|
||||
now: () => 1_700,
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions),
|
||||
readChildWork: () => childWork
|
||||
})
|
||||
const outcomes: [string, string][] = []
|
||||
feed.subscribe({
|
||||
id: 'sub',
|
||||
emit: (event) => {
|
||||
if (event.type === 'completion') {
|
||||
outcomes.push([event.completion.turnId, event.completion.outcome])
|
||||
}
|
||||
}
|
||||
})
|
||||
const commit = (next: AgentJournalRenderItem[], nextSubmissions: AgentJournalSubmission[]) => {
|
||||
items = next
|
||||
submissions = nextSubmissions
|
||||
sequence += 1
|
||||
feed.observe('session-1')
|
||||
}
|
||||
return {
|
||||
outcomes,
|
||||
feed,
|
||||
commit,
|
||||
settle: (lifecycle: AgentJournalTurnLifecycle) => {
|
||||
commit([turnItem({ ...lifecycle, state: 'running', outcome: undefined })], [])
|
||||
commit([turnItem(lifecycle)], [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('a turn that settles while its child work is still live', () => {
|
||||
it('holds the finish until the last subagent drains, then announces that turn once', () => {
|
||||
const childWork: AgentChildWorkLivenessCandidate[] = [SUBAGENT]
|
||||
const h = harness(childWork)
|
||||
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
|
||||
expect(h.outcomes).toEqual([])
|
||||
|
||||
childWork[0] = { ...SUBAGENT, state: 'done' }
|
||||
h.feed.observe('session-1')
|
||||
h.feed.observe('session-1')
|
||||
expect(h.outcomes).toEqual([['turn-1', 'success']])
|
||||
})
|
||||
|
||||
it('holds a coordinator waiting on workers through a background orca orchestration check --wait', () => {
|
||||
const h = harness([{ ...DEV_SERVER, operation: { input: 'orca orchestration check --wait' } }])
|
||||
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
|
||||
expect(h.outcomes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not hold a failed turn', () => {
|
||||
const h = harness([SUBAGENT])
|
||||
h.settle({ turnId: 'turn-1', state: 'completed', outcome: 'failure' })
|
||||
expect(h.outcomes).toEqual([['turn-1', 'failure']])
|
||||
})
|
||||
|
||||
it('lets a dev server hold only a turn the agent started itself, with nothing steered into it', () => {
|
||||
const user = harness([DEV_SERVER])
|
||||
user.settle({ turnId: 'turn-1', state: 'completed', outcome: 'success' })
|
||||
expect(user.outcomes).toEqual([['turn-1', 'success']])
|
||||
|
||||
const woken = harness([DEV_SERVER])
|
||||
woken.settle(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' }))
|
||||
expect(woken.outcomes).toEqual([])
|
||||
|
||||
const steered = harness([DEV_SERVER])
|
||||
const steer: AgentJournalSubmission = {
|
||||
clientMessageId: 'm1',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'm1',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 10,
|
||||
resolvedAt: 20,
|
||||
handoverRecorded: true,
|
||||
dispatchState: 'accepted',
|
||||
answeredInTurn: { turnItemId: 'codex:turn:wake-1', via: 'steer' }
|
||||
}
|
||||
const message: AgentJournalRenderItem = {
|
||||
itemId: agentJournalSubmissionKey('m1'),
|
||||
revision: 0,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'm1' }] }
|
||||
}
|
||||
steered.commit(
|
||||
[message, turnItem(selfStarted({ turnId: 'wake-1', state: 'running' }))],
|
||||
[steer]
|
||||
)
|
||||
steered.commit(
|
||||
[
|
||||
message,
|
||||
turnItem(selfStarted({ turnId: 'wake-1', state: 'completed', outcome: 'success' }))
|
||||
],
|
||||
[steer]
|
||||
)
|
||||
expect(steered.outcomes).toEqual([['wake-1', 'success']])
|
||||
})
|
||||
})
|
||||
+2
-1
@@ -100,7 +100,8 @@ function harness(): {
|
||||
const feed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now: () => 1_700,
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence)
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items, submissions, fence),
|
||||
readChildWork: () => []
|
||||
})
|
||||
const events: AgentSessionTurnCompletionEvent[] = []
|
||||
return {
|
||||
|
||||
+30
-1
@@ -10,6 +10,14 @@ import type {
|
||||
AgentSessionAttentionEdge,
|
||||
StructuredAttentionState
|
||||
} from '../../../shared/agent-session/agent-session-attention'
|
||||
import {
|
||||
foldAgentLeadStatus,
|
||||
heldTurnCountsAsFinished
|
||||
} from '../../../shared/agent-lead-status-fold'
|
||||
import {
|
||||
agentChildWorkLiveness,
|
||||
type AgentChildWorkLivenessCandidate
|
||||
} from '../../../shared/agent-status-child-work-liveness'
|
||||
import type { StructuredAgentSessionLatestRequest } from '../../../shared/agent-session/requests/structured-agent-session-latest-request'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionStatusState } from './structured-agent-session-status-feed'
|
||||
@@ -38,6 +46,7 @@ export type StructuredAgentSessionTurnCompletionFeedDeps = {
|
||||
sessionId: string,
|
||||
journal?: AgentSessionJournal
|
||||
) => StructuredAgentSessionStatusState | null
|
||||
readChildWork: (sessionId: string) => readonly AgentChildWorkLivenessCandidate[] | undefined
|
||||
}
|
||||
|
||||
type RequestMark = Pick<StructuredAgentSessionLatestRequest, 'kind' | 'id'>
|
||||
@@ -173,7 +182,8 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
if (
|
||||
state.owesWork ||
|
||||
!request ||
|
||||
(baseline.settled?.kind === request.kind && baseline.settled.id === request.id)
|
||||
(baseline.settled?.kind === request.kind && baseline.settled.id === request.id) ||
|
||||
this.heldByLiveWork(sessionId, state, request)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -193,6 +203,25 @@ export class StructuredAgentSessionTurnCompletionFeed {
|
||||
}
|
||||
}
|
||||
|
||||
/** A successful turn is finished only when the combined row would be done; a held request stays unaccounted so a later observe can announce it. */
|
||||
private heldByLiveWork(
|
||||
sessionId: string,
|
||||
state: StructuredAgentSessionStatusState,
|
||||
request: StructuredAgentSessionLatestRequest
|
||||
): boolean {
|
||||
if (request.outcome !== 'success' || state.summary.status === 'attention') {
|
||||
return false
|
||||
}
|
||||
const held = foldAgentLeadStatus({
|
||||
leadState: 'done',
|
||||
childWorkLiveness: agentChildWorkLiveness(this.deps.readChildWork(sessionId))
|
||||
})
|
||||
return (
|
||||
held.stateName !== 'done' &&
|
||||
!heldTurnCountsAsFinished(held, request.outcome, request.selfStarted === true)
|
||||
)
|
||||
}
|
||||
|
||||
private broadcast(
|
||||
event: AgentSessionAttentionEdge,
|
||||
audience: 'all' | 'legacy' | 'prompt-aware'
|
||||
|
||||
@@ -114,6 +114,7 @@ describe('host prompt delivery and retirement', () => {
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items),
|
||||
readChildWork: () => undefined,
|
||||
now: () => 42
|
||||
})
|
||||
feed.subscribe({ id: 'legacy', emit: (event) => legacy.push(event) })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Share desktop completion/input timing and hook rows; phone/connector listeners choose wording and transport.
|
||||
import type { AgentPaneChange } from '../agent-hooks/agent-transition-detector'
|
||||
import { mainAgentTurnInterrupted } from '../../shared/agent-lead-status-fold'
|
||||
import { agentTurnEndedOnPurpose } from '../../shared/agent-main-agent-verdict'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload'
|
||||
import { AGENT_DONE_QUIET_MS } from '../../shared/agent-transitions'
|
||||
@@ -23,6 +24,8 @@ export type TerminalAgentTurnNewsDeps<Prepared> = {
|
||||
stopped?: (news: TerminalAgentTurnNewsItem) => void
|
||||
/** Opt-in: a finish is news only once the pane's children settle too, not when its lead agent does. */
|
||||
allClearOnly?: true
|
||||
/** Opt-in: every turn the lead agent ends is news, even one the host holds back from notifications while children run. */
|
||||
everyLeadTurn?: true
|
||||
}
|
||||
|
||||
type PendingFinish = {
|
||||
@@ -60,10 +63,11 @@ export class TerminalAgentTurnNews<Prepared> {
|
||||
const finished = transition === 'turn-finished' || transition === 'errored'
|
||||
const row = finished || transition === 'needs-input' ? this.deps.readHookRow(paneKey) : null
|
||||
// The all-clear of a turn announced when its lead agent finished: that finish still stands.
|
||||
const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined
|
||||
if (
|
||||
finished &&
|
||||
row?.turnCompletedAt !== undefined &&
|
||||
this.finishedTurnByPane.get(paneKey) === row.turnCompletedAt
|
||||
leadTurnEndedAt !== undefined &&
|
||||
this.finishedTurnByPane.get(paneKey) === leadTurnEndedAt
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -116,7 +120,7 @@ export class TerminalAgentTurnNews<Prepared> {
|
||||
if (!row || row.state !== 'working' || !worktreeId) {
|
||||
return
|
||||
}
|
||||
const { turnCompletedAt } = row
|
||||
const turnCompletedAt = this.leadTurnEndedAt(row)
|
||||
if (turnCompletedAt === undefined || this.deps.allClearOnly) {
|
||||
this.workingSeen.add(paneKey)
|
||||
return
|
||||
@@ -140,14 +144,26 @@ export class TerminalAgentTurnNews<Prepared> {
|
||||
private seed(paneKey: string): void {
|
||||
const row = this.deps.readHookRow(paneKey)
|
||||
// An all-clear listener never heard the lead's finish, so its stamp announces nothing.
|
||||
if (row?.turnCompletedAt !== undefined && !this.deps.allClearOnly) {
|
||||
this.finishedTurnByPane.set(paneKey, row.turnCompletedAt)
|
||||
const leadTurnEndedAt = row ? this.leadTurnEndedAt(row) : undefined
|
||||
if (leadTurnEndedAt !== undefined && !this.deps.allClearOnly) {
|
||||
this.finishedTurnByPane.set(paneKey, leadTurnEndedAt)
|
||||
}
|
||||
if (row?.state === 'working') {
|
||||
this.workingSeen.add(paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's announce stamp; for every-lead-turn listeners, else the end of a lead turn its children still hold open. */
|
||||
private leadTurnEndedAt(row: AgentStatusIpcPayload): number | undefined {
|
||||
if (row.turnCompletedAt !== undefined || !this.deps.everyLeadTurn) {
|
||||
return row.turnCompletedAt
|
||||
}
|
||||
const lead = row.mainAgent
|
||||
return lead?.state === 'done' && !mainAgentTurnInterrupted(lead)
|
||||
? lead.stateStartedAt
|
||||
: undefined
|
||||
}
|
||||
|
||||
private announce(
|
||||
paneKey: string,
|
||||
worktreeId: string,
|
||||
|
||||
+75
-27
@@ -155,41 +155,90 @@ describe('Claude background-turn completion notifications', () => {
|
||||
return { banners, rows }
|
||||
}
|
||||
|
||||
it('announces a subagent turn at Stop with that turn’s text, not when the child later drains', async () => {
|
||||
const { banners, rows } = await play([
|
||||
// Real Claude 2.1.287 PTY hooks (see the fixture's scenarios): every background-subagent run announces once, after the wake-up turn that consumes the last result.
|
||||
it.each([
|
||||
'one-subagent',
|
||||
'two-subagents-together',
|
||||
'subagent-resumed',
|
||||
'subagent-with-own-shell',
|
||||
'subagent-idle-ctrl-c'
|
||||
])('announces the %s capture once, when everything has drained', async (scenario) => {
|
||||
const records: {
|
||||
scenario: string
|
||||
t: number
|
||||
kind: string
|
||||
payload?: Record<string, unknown>
|
||||
}[] = (
|
||||
await readFile(
|
||||
new URL(
|
||||
'../../../shared/__fixtures__/claude-task-notification-hooks.jsonl',
|
||||
import.meta.url
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
const events = records.flatMap((record) =>
|
||||
record.scenario === scenario && record.kind === 'hook' && record.payload
|
||||
? [{ at: 1_700_000_000_000 + Math.round(record.t * 1000), payload: record.payload }]
|
||||
: []
|
||||
)
|
||||
const lastStop = events.findLast((event) => event.payload.hook_event_name === 'Stop')
|
||||
|
||||
const { banners } = await play(events)
|
||||
|
||||
// A plain done waits out the quiet window before it is announced.
|
||||
expect(banners.map((banner) => banner.at)).toEqual([(lastStop?.at ?? 0) + HOOK_DONE_QUIET_MS])
|
||||
})
|
||||
|
||||
it('announces a coordinator waiting on workers in the background once, at its final turn', async () => {
|
||||
const wait = 'orca orchestration check --wait --types worker_done,escalation --json'
|
||||
const waitFor = (id: string, at: number) => [
|
||||
{
|
||||
at: 1_700_000_000_000,
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }
|
||||
},
|
||||
{
|
||||
at: 1_700_000_001_000,
|
||||
at,
|
||||
payload: {
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a1',
|
||||
agent_type: 'general-purpose'
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: wait, run_in_background: true },
|
||||
tool_response: { backgroundTaskId: id }
|
||||
}
|
||||
},
|
||||
{
|
||||
at: 1_700_000_006_000,
|
||||
at: at + 1_000,
|
||||
payload: {
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'Which cells need hand-verification?',
|
||||
background_tasks: [{ id: 'a1', type: 'subagent', status: 'running' }]
|
||||
last_assistant_message: 'Waiting for workers.',
|
||||
background_tasks: [{ id, type: 'shell', status: 'running', command: wait }]
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
const wake = (id: string, at: number) => ({
|
||||
at,
|
||||
payload: {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: `<task-notification>\n<task-id>${id}</task-id>\n<status>completed</status>\n</task-notification>`
|
||||
}
|
||||
})
|
||||
|
||||
const { banners } = await play([
|
||||
{ at: 1_700_000_000_000, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'run it' } },
|
||||
...waitFor('w1', 1_700_000_001_000),
|
||||
wake('w1', 1_700_000_060_000),
|
||||
...waitFor('w2', 1_700_000_061_000),
|
||||
wake('w2', 1_700_000_120_000),
|
||||
{
|
||||
at: 1_700_000_055_000,
|
||||
payload: { hook_event_name: 'SubagentStop', agent_id: 'a1' }
|
||||
at: 1_700_000_121_000,
|
||||
payload: {
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'All workers done.',
|
||||
background_tasks: []
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const stop = rows.find((row) => row.lastAssistantMessage?.includes('hand-verification'))
|
||||
expect(stop?.state).toBe('working')
|
||||
expect(stop?.turnCompletedAt).toBe(1_700_000_006_000)
|
||||
expect(banners).toHaveLength(1)
|
||||
expect(banners[0]?.at).toBe(1_700_000_006_000)
|
||||
expect(banners[0]?.body).toBe('Which cells need hand-verification?')
|
||||
expect(banners[0]?.stateStartedAt).toBe(1_700_000_006_000)
|
||||
expect(banners).toEqual([expect.objectContaining({ body: 'All workers done.' })])
|
||||
})
|
||||
|
||||
it('notifies each background-shell turn with a distinct notification identity', async () => {
|
||||
@@ -234,7 +283,7 @@ describe('Claude background-turn completion notifications', () => {
|
||||
expect(banners[0]?.stateStartedAt).not.toBe(banners[1]?.stateStartedAt)
|
||||
})
|
||||
|
||||
it('applies the provider retirement to visible rows and quit capture without announcing the parent twice', async () => {
|
||||
it('applies the provider retirement to visible rows and quit capture, announcing the parent once it is done', async () => {
|
||||
const { observeAgentHookCompletionForNotification } =
|
||||
await import('./agent-hook-completion-notifications')
|
||||
actualStore = createTestStore()
|
||||
@@ -321,14 +370,14 @@ describe('Claude background-turn completion notifications', () => {
|
||||
}
|
||||
const before = store.getState().agentStatusByPaneKey[PANE_KEY]!
|
||||
const beforeEvent = current!
|
||||
expect(beforeEvent.payload.turnCompletedAt).toBe(Date.parse(kill.timestamp) - 1_000)
|
||||
expect(beforeEvent.payload.turnCompletedAt).toBeUndefined()
|
||||
expect(before.turnEndedAt).toBeUndefined()
|
||||
expect(before.turnEndDisposition).toBeUndefined()
|
||||
expect(before.subagents?.[0]?.id).toBe('a123')
|
||||
poller.schedule(current!)
|
||||
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
|
||||
await poller.poll(PANE_KEY)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
await appendFile(path, `${JSON.stringify(kill)}\n`)
|
||||
await poller.poll(PANE_KEY)
|
||||
vi.advanceTimersByTime(HOOK_DONE_QUIET_MS)
|
||||
@@ -338,7 +387,6 @@ describe('Claude background-turn completion notifications', () => {
|
||||
expect(after.mainAgent).toEqual(before.mainAgent)
|
||||
expect(current?.hookEventName).toBe('SubagentStop')
|
||||
expect(current?.toolAgentId).toBe('a123')
|
||||
expect(current?.payload.turnCompletedAt).toBe(beforeEvent.payload.turnCompletedAt)
|
||||
expect(after.turnEndedAt).toBeUndefined()
|
||||
expect(after.turnEndDisposition).toBeUndefined()
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -41,47 +41,6 @@ describe('shared agent-hook-listener', () => {
|
||||
expect(stop?.payload.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stamps turnCompletedAt on a gated lead Stop and repeats it on the all-clear', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(1_700_000_005_000)
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
|
||||
claudeEvent({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a1',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
const stop = claudeEvent({
|
||||
hook_event_name: 'Stop',
|
||||
last_assistant_message: 'Which cells need hand-verification?',
|
||||
background_tasks: [
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'subagent',
|
||||
status: 'running',
|
||||
description: 'Review loop',
|
||||
agent_type: 'general-purpose'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(stop?.payload.state).toBe('working')
|
||||
expect(stop?.payload.turnCompletedAt).toBe(1_700_000_005_000)
|
||||
expect(stop?.payload.lastAssistantMessage).toBe('Which cells need hand-verification?')
|
||||
|
||||
vi.setSystemTime(1_700_000_055_000)
|
||||
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
|
||||
expect(drained?.payload.state).toBe('done')
|
||||
expect(drained?.payload.turnCompletedAt).toBe(1_700_000_005_000)
|
||||
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'next turn' })
|
||||
const nextStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
|
||||
expect(nextStop?.payload.state).toBe('done')
|
||||
expect(nextStop?.payload.turnCompletedAt).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not stamp an interrupted Stop even while a child still gates the pane', () => {
|
||||
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' })
|
||||
claudeEvent({
|
||||
@@ -717,14 +676,12 @@ describe('shared agent-hook-listener', () => {
|
||||
// The lead is done; answering resumes the child and holds working only while it runs.
|
||||
expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({
|
||||
state: 'working',
|
||||
turnCompletedAt: expect.any(Number),
|
||||
// The main agent's own fact rides beside the gated state: it finished, no verdict was given.
|
||||
mainAgent: { state: 'done', stateStartedAt: expect.any(Number) }
|
||||
})
|
||||
expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({
|
||||
state: 'done',
|
||||
stateStartedAt: expect.any(Number),
|
||||
turnCompletedAt: expect.any(Number)
|
||||
stateStartedAt: expect.any(Number)
|
||||
})
|
||||
|
||||
const drained = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'a1' })
|
||||
|
||||
@@ -134,14 +134,8 @@ describe('Claude owed task notifications outside the captures', () => {
|
||||
|
||||
// A stamped working row is announced at once; this Stop is ~100 ms from the wake-up turn.
|
||||
const owedOnly = post({ hook_event_name: 'Stop', background_tasks: [] })
|
||||
expect(owedOnly?.payload.state).toBe('working')
|
||||
expect(owedOnly?.payload.turnCompletedAt).toBeUndefined()
|
||||
|
||||
launchAgent(post, 'a2')
|
||||
const childRunning = post({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'a2', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
expect(childRunning?.payload.turnCompletedAt).toEqual(expect.any(Number))
|
||||
})
|
||||
|
||||
it('drops what a replaced conversation was owed', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ParsedAgentStatusPayload } from '../../agent-status-types'
|
||||
import { isAgentStatusHeldOpenByChildWork } from '../../agent-lead-status-fold'
|
||||
import {
|
||||
heldTurnCountsAsFinished,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
} from '../../agent-lead-status-fold'
|
||||
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
|
||||
import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory'
|
||||
import {
|
||||
@@ -281,7 +284,8 @@ export function normalizeClaudeEvent(
|
||||
})
|
||||
// Reuse resolved state for compact ownership instead of folding twice (#15202).
|
||||
const effectiveState = resolvedStatus.stateName
|
||||
// Pin held turn identity by end time for later all-clear pairing; cancelled turns earn no completion stamp.
|
||||
const ownPreviousLead = previousLead?.waitingAgentId ? previousLead.stateBeforeWait : previousLead
|
||||
// Stamp (announce now, pair the later all-clear) only a held turn that already counts as finished; cancelled turns earn none.
|
||||
const turnCompletedAt =
|
||||
eventAgentId === undefined &&
|
||||
isTurnBoundary &&
|
||||
@@ -290,6 +294,7 @@ export function normalizeClaudeEvent(
|
||||
state: resolvedStatus.stateName,
|
||||
mainAgent: { state: reportedStateName }
|
||||
}) &&
|
||||
heldTurnCountsAsFinished(resolvedStatus, outcome, ownPreviousLead?.taskWakeupTurn === true) &&
|
||||
// Owed notifications wake the lead within ~100ms; announcing first would duplicate completion.
|
||||
claudePaneHasRunningChildWork(state, paneKey)
|
||||
? Date.now()
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold'
|
||||
import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness'
|
||||
import { claudeLiveOwedTaskNotificationKinds } from '../../claude-owed-task-notifications'
|
||||
import {
|
||||
claudeLiveOwedTaskNotificationKinds,
|
||||
claudeTasksAwaitAgents
|
||||
} from '../../claude-owed-task-notifications'
|
||||
import { claudeRosterHasWorkingSubagent } from '../../claude-subagent-roster'
|
||||
import type { ClaudeLeadTurnState, HookListenerState } from '../listener-state'
|
||||
import { mainAgentIdleSince } from '../main-agent-turn-state'
|
||||
|
||||
/** Everything that can hold a pane whose main agent's own turn is over. */
|
||||
type ClaudePaneHoldEvidence = {
|
||||
/** A sub-agent the roster tracks as working. */
|
||||
/** A sub-agent the roster tracks as working, or a shell waiting on worker agents. */
|
||||
runningAgent: boolean
|
||||
/** A running shell or a scheduled session cron. */
|
||||
runningNonAgent: boolean
|
||||
@@ -24,13 +27,16 @@ export function claudePaneHoldEvidence(
|
||||
paneKey
|
||||
)
|
||||
): ClaudePaneHoldEvidence {
|
||||
const launched = state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey)
|
||||
const owed = claudeLiveOwedTaskNotificationKinds(
|
||||
state.claudeLaunchedBackgroundTasksByPaneKey.get(paneKey),
|
||||
launched,
|
||||
mainAgent?.state === 'done',
|
||||
mainAgentIdleSince(state.claudeLeadStateByPaneKey.get(paneKey))
|
||||
)
|
||||
return {
|
||||
runningAgent: claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)),
|
||||
runningAgent:
|
||||
claudeRosterHasWorkingSubagent(state.claudeSubagentRosterByPaneKey.get(paneKey)) ||
|
||||
claudeTasksAwaitAgents(launched),
|
||||
runningNonAgent:
|
||||
state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
|
||||
state.claudeActiveSessionCronPaneKeys.has(paneKey),
|
||||
|
||||
@@ -23,7 +23,7 @@ export function trackClaudeTaskNotificationDelivery(
|
||||
if (eventName === 'PostToolUse') {
|
||||
const toolName = readString(hookPayload, 'tool_name')
|
||||
const response = hookPayload['tool_response']
|
||||
const launch = readClaudeBackgroundTaskLaunch(toolName, response)
|
||||
const launch = readClaudeBackgroundTaskLaunch(toolName, response, hookPayload['tool_input'])
|
||||
if (launch) {
|
||||
const launched = tasks ?? new Map()
|
||||
state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, launched)
|
||||
@@ -57,8 +57,22 @@ export function trackClaudeTaskNotificationDelivery(
|
||||
settleClaudeTaskNotification(tasks, notification.taskId)
|
||||
}
|
||||
return knownTask || continuesWakeupTurn
|
||||
} else if (tasks && (eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) {
|
||||
oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now())
|
||||
} else if ((eventName === 'Stop' || eventName === 'StopFailure') && inventory.present) {
|
||||
if (inventory.runningAgentWaitIds.length > 0) {
|
||||
tasks ??= new Map()
|
||||
state.claudeLaunchedBackgroundTasksByPaneKey.set(paneKey, tasks)
|
||||
for (const id of inventory.runningAgentWaitIds) {
|
||||
recordClaudeBackgroundTaskLaunch(tasks, {
|
||||
id,
|
||||
kind: 'shell',
|
||||
awaitsAgents: true,
|
||||
listedOnly: true
|
||||
})
|
||||
}
|
||||
}
|
||||
if (tasks) {
|
||||
oweClaudeShellTaskNotifications(tasks, new Set(inventory.runningNonAgentTaskIds), Date.now())
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -40,6 +40,15 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS
|
||||
return { stateName: 'done' }
|
||||
}
|
||||
|
||||
/** A turn held open by live work still counts as finished when it failed, or when only monitoring holds a turn the user started; agent work holds it until the row reaches done, and a turn the agent started itself stays held by a monitor that will wake it again. */
|
||||
export function heldTurnCountsAsFinished(
|
||||
resolution: AgentLeadStatusResolution,
|
||||
outcome: AgentMainAgentStatus['outcome'],
|
||||
selfStarted: boolean
|
||||
): boolean {
|
||||
return outcome === 'failure' || (resolution.workingMode === 'monitoring' && !selfStarted)
|
||||
}
|
||||
|
||||
/** Derive child-held status from lead and combined states; storing it could contradict either fact. */
|
||||
export function isAgentStatusHeldOpenByChildWork(row: {
|
||||
state: AgentStatusState
|
||||
|
||||
@@ -33,6 +33,8 @@ export type StructuredAgentSessionLatestRequest = {
|
||||
outcome: AgentJournalTurnOutcome | null
|
||||
/** When it settled: the turn's end, or the refusal. Undefined while it runs. */
|
||||
settledAt: number | undefined
|
||||
/** The agent opened this turn itself (e.g. woken by a background task) and the user steered nothing into it. */
|
||||
selfStarted?: true
|
||||
}
|
||||
|
||||
/** Commands and steers have no independent request; their turns own any response. */
|
||||
@@ -78,7 +80,10 @@ export function latestStructuredAgentSessionRequest(
|
||||
id: turn.turnId,
|
||||
turnState: turn.state,
|
||||
outcome: readAgentJournalTurnOutcome(turn),
|
||||
settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn)
|
||||
settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn),
|
||||
...(turn.userItemId === item.itemId && !steeredInto(submissions, item.itemId)
|
||||
? { selfStarted: true as const }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
const submission = rejected.get(item.itemId)
|
||||
@@ -100,6 +105,14 @@ export function latestStructuredAgentSessionRequest(
|
||||
return null
|
||||
}
|
||||
|
||||
/** A user message steered into a turn the agent started makes it the user's turn too. */
|
||||
function steeredInto(submissions: readonly AgentJournalSubmission[], turnItemId: string): boolean {
|
||||
return submissions.some(
|
||||
({ answeredInTurn }) =>
|
||||
answeredInTurn?.via === 'steer' && answeredInTurn.turnItemId === turnItemId
|
||||
)
|
||||
}
|
||||
|
||||
/** Exclude sends that failed nobody and never became turns; include provider-journaled user rows, even from children/history/old hosts. */
|
||||
export function hasStructuredAgentSessionRequest(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ORCA_ORCHESTRATION_WAIT_COMMAND } from './orca-cli-invocations'
|
||||
import type {
|
||||
AgentChildWorkKind,
|
||||
AgentChildWorkMembership,
|
||||
@@ -10,6 +11,8 @@ export type AgentChildWorkLiveness = 'waiting' | 'working' | 'monitoring' | null
|
||||
export type AgentChildWorkLivenessCandidate = {
|
||||
kind: AgentChildWorkKind
|
||||
state?: AgentChildWorkState
|
||||
/** What the child runs; a producer marks a worker wait with the canonical wait command, which is agent work. */
|
||||
operation?: { input?: string } | null
|
||||
}
|
||||
|
||||
export type AgentChildWorkLivenessEvidence = {
|
||||
@@ -24,6 +27,12 @@ export function isAgentChildWorkKind(kind: AgentChildWorkKind): boolean {
|
||||
return kind === 'agent'
|
||||
}
|
||||
|
||||
function isAgentChildWork(child: AgentChildWorkLivenessCandidate): boolean {
|
||||
return (
|
||||
isAgentChildWorkKind(child.kind) || child.operation?.input === ORCA_ORCHESTRATION_WAIT_COMMAND
|
||||
)
|
||||
}
|
||||
|
||||
/** Only explicit settlement retires work: missing states, unknown kinds and lost contact stay active. Roster lifetime bounds this fail-active policy (session close; Claude provider ended); in-place blocked failures would remain working until then. */
|
||||
export function isLiveChildWork(child: AgentChildWorkLivenessCandidate): boolean {
|
||||
return child.state !== 'done' && child.state !== 'idle'
|
||||
@@ -57,8 +66,9 @@ export function agentChildWorkLiveness(
|
||||
continue
|
||||
}
|
||||
hasWaitingChildWork ||= isWaitingChildWork(child)
|
||||
hasLiveAgentWork ||= isAgentChildWorkKind(child.kind)
|
||||
hasLiveNonAgentWork ||= !isAgentChildWorkKind(child.kind)
|
||||
const agentWork = isAgentChildWork(child)
|
||||
hasLiveAgentWork ||= agentWork
|
||||
hasLiveNonAgentWork ||= !agentWork
|
||||
}
|
||||
return agentChildWorkLivenessFromEvidence({
|
||||
hasWaitingChildWork,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { classifyClaudeBackgroundTaskKind } from './claude-background-task-kind'
|
||||
import { isAgentChildWorkKind } from './agent-status-child-work-liveness'
|
||||
import { runsOrcaOrchestrationWait } from './orca-cli-invocations'
|
||||
|
||||
const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
|
||||
'idle',
|
||||
@@ -41,6 +42,8 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
|
||||
hasRunningNonAgentTask: boolean
|
||||
/** Ids of the running shell-like entries; one without an id is counted but not listed. */
|
||||
runningNonAgentTaskIds: string[]
|
||||
/** Running shells blocked on `orca orchestration check --wait`, which recovers them after a listener restart. */
|
||||
runningAgentWaitIds: string[]
|
||||
} {
|
||||
const raw = hookPayload['background_tasks']
|
||||
if (!Array.isArray(raw)) {
|
||||
@@ -49,13 +52,15 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
|
||||
tasks: [],
|
||||
truncated: false,
|
||||
hasRunningNonAgentTask: false,
|
||||
runningNonAgentTaskIds: []
|
||||
runningNonAgentTaskIds: [],
|
||||
runningAgentWaitIds: []
|
||||
}
|
||||
}
|
||||
const tasks: ClaudeBackgroundAgentTask[] = []
|
||||
let truncated = false
|
||||
let hasRunningNonAgentTask = false
|
||||
const runningNonAgentTaskIds: string[] = []
|
||||
const runningAgentWaitIds: string[] = []
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
truncated = true
|
||||
@@ -79,6 +84,9 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
|
||||
const id = typeof obj.id === 'string' ? obj.id.trim() : ''
|
||||
if (id.length > 0) {
|
||||
runningNonAgentTaskIds.push(id)
|
||||
if (runsOrcaOrchestrationWait(obj.command)) {
|
||||
runningAgentWaitIds.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isAgentTask) {
|
||||
@@ -96,5 +104,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
|
||||
teammate: taskType === 'teammate'
|
||||
})
|
||||
}
|
||||
return { present: true, tasks, truncated, hasRunningNonAgentTask, runningNonAgentTaskIds }
|
||||
return {
|
||||
present: true,
|
||||
tasks,
|
||||
truncated,
|
||||
hasRunningNonAgentTask,
|
||||
runningNonAgentTaskIds,
|
||||
runningAgentWaitIds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('Claude background task status', () => {
|
||||
expect(typeof monitoring?.turnCompletedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('stamps a lead that ends into a working subagent, which carries no monitoring mode', () => {
|
||||
it('holds the finish of a lead that ends into a working subagent, unless the lead failed', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' })
|
||||
@@ -199,10 +199,79 @@ describe('Claude background task status', () => {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
|
||||
// Child work keeps the spinner, but the ended lead still needs a notification stamp.
|
||||
expect(childWorking).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(typeof childWorking?.turnCompletedAt).toBe('number')
|
||||
expect(childWorking?.turnCompletedAt).toBeUndefined()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'again' })
|
||||
const failed = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'StopFailure',
|
||||
background_tasks: [{ id: 'child-1', type: 'subagent', status: 'running' }]
|
||||
})
|
||||
expect(failed).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(typeof failed?.turnCompletedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('holds a coordinator waiting on workers in the background, even across a listener restart', () => {
|
||||
const wait = {
|
||||
id: 'wait-1',
|
||||
type: 'shell',
|
||||
status: 'running',
|
||||
description: 'Wait for next worker message',
|
||||
command:
|
||||
'/usr/local/bin/orca orchestration check --wait --types worker_done --json 2>/dev/null'
|
||||
}
|
||||
const launched = createHookListenerState()
|
||||
claudeEvent(launched, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' })
|
||||
claudeEvent(launched, SOURCE_PANE, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: wait.command, run_in_background: true },
|
||||
tool_response: { backgroundTaskId: 'wait-1' }
|
||||
})
|
||||
// Only the dev server is still listed; the ended wait's notification is still owed.
|
||||
const owed = claudeEvent(launched, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
expect(owed).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(owed?.turnCompletedAt).toBeUndefined()
|
||||
|
||||
const restarted = createHookListenerState()
|
||||
claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run' })
|
||||
const listed = claudeEvent(restarted, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [wait]
|
||||
})
|
||||
expect(listed).toMatchObject({ state: 'working', workingMode: undefined })
|
||||
expect(listed?.turnCompletedAt).toBeUndefined()
|
||||
// Only listed, it may be a subagent's wait, whose result never reaches this agent.
|
||||
expect(
|
||||
claudeEvent(restarted, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [] })?.state
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('does not stamp a wake-up turn that ends with only a monitor that will wake it again', () => {
|
||||
const state = createHookListenerState()
|
||||
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'watch' })
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'sleep 15', run_in_background: true },
|
||||
tool_response: { backgroundTaskId: RUNNING_SHELL.id }
|
||||
})
|
||||
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
|
||||
claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: `<task-notification>\n<task-id>${RUNNING_SHELL.id}</task-id>\n<event>tick</event>`
|
||||
})
|
||||
const wakeEnd = claudeEvent(state, SOURCE_PANE, {
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [RUNNING_SHELL]
|
||||
})
|
||||
|
||||
expect(wakeEnd).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
expect(wakeEnd?.turnCompletedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not stamp a turn that is still running in the foreground', () => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isRecord } from './agent-status-child-work-value-guards'
|
||||
import { runsOrcaOrchestrationWait } from './orca-cli-invocations'
|
||||
|
||||
/** Main-agent background tasks keyed by launch/notification task ID. Notifications can arrive a turn after task end and an empty Stop; owed work keeps the pane active (#23942). Captures: __fixtures__/claude-task-notification-hooks.jsonl. */
|
||||
export type ClaudeLaunchedBackgroundTasks = Map<string, ClaudeLaunchedBackgroundTask>
|
||||
|
||||
@@ -11,6 +14,10 @@ type ClaudeLaunchedBackgroundTask = {
|
||||
settled?: true
|
||||
/** A delivered notification, distinct from a lease that merely gave up waiting. */
|
||||
notificationDelivered?: true
|
||||
/** A shell blocked on worker agents (`orca orchestration check --wait`): agent work, not monitoring. */
|
||||
awaitsAgents?: true
|
||||
/** Known only from the main agent's task list (e.g. after a listener restart): a subagent may own it, so it owes the main agent nothing. */
|
||||
listedOnly?: true
|
||||
}
|
||||
|
||||
/** An idle notification lease is expectation, not proof: Stop never promises an empty queue and posts can be lost/reordered. Captured deliveries took 40–130ms; Claude idle_prompt consistently fired after 60s. */
|
||||
@@ -22,8 +29,13 @@ const CLAUDE_LAUNCHED_BACKGROUND_TASK_LIMIT = 256
|
||||
/** The task id a main-agent PostToolUse reports for work it left running in the background. */
|
||||
export function readClaudeBackgroundTaskLaunch(
|
||||
toolName: string | undefined,
|
||||
toolResponse: unknown
|
||||
): { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] } | null {
|
||||
toolResponse: unknown,
|
||||
toolInput?: unknown
|
||||
):
|
||||
| (Pick<ClaudeLaunchedBackgroundTask, 'kind' | 'awaitsAgents'> & {
|
||||
id: string
|
||||
})
|
||||
| null {
|
||||
if (typeof toolResponse !== 'object' || toolResponse === null) {
|
||||
return null
|
||||
}
|
||||
@@ -37,22 +49,40 @@ export function readClaudeBackgroundTaskLaunch(
|
||||
const rawShellId =
|
||||
response.backgroundTaskId ?? (toolName === 'Monitor' ? response.taskId : undefined)
|
||||
const shellId = typeof rawShellId === 'string' ? rawShellId.trim() : ''
|
||||
return shellId.length > 0 ? { id: shellId, kind: 'shell' } : null
|
||||
if (shellId.length === 0) {
|
||||
return null
|
||||
}
|
||||
const command = isRecord(toolInput) ? toolInput.command : undefined
|
||||
return {
|
||||
id: shellId,
|
||||
kind: 'shell',
|
||||
...(runsOrcaOrchestrationWait(command) ? { awaitsAgents: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function recordClaudeBackgroundTaskLaunch(
|
||||
tasks: ClaudeLaunchedBackgroundTasks,
|
||||
launch: { id: string; kind: ClaudeLaunchedBackgroundTask['kind'] }
|
||||
launch: Pick<ClaudeLaunchedBackgroundTask, 'kind' | 'awaitsAgents' | 'listedOnly'> & {
|
||||
id: string
|
||||
}
|
||||
): void {
|
||||
const previous = tasks.get(launch.id)
|
||||
if (previous) {
|
||||
if (previous.kind === launch.kind) {
|
||||
previous.launchUnconfirmed = undefined
|
||||
previous.awaitsAgents ||= launch.awaitsAgents
|
||||
if (!launch.listedOnly) {
|
||||
previous.launchUnconfirmed = undefined
|
||||
previous.listedOnly = undefined
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (makeClaudeTaskRecordRoom(tasks)) {
|
||||
tasks.set(launch.id, { kind: launch.kind })
|
||||
tasks.set(launch.id, {
|
||||
kind: launch.kind,
|
||||
...(launch.awaitsAgents ? { awaitsAgents: true as const } : {}),
|
||||
...(launch.listedOnly ? { listedOnly: true as const } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +114,11 @@ export function recordClaudeUnconfirmedAgentEnd(
|
||||
now: number
|
||||
): void {
|
||||
if (!tasks.has(agentId) && makeClaudeTaskRecordRoom(tasks)) {
|
||||
tasks.set(agentId, { kind: 'agent', launchUnconfirmed: true, notificationOwedAt: now })
|
||||
tasks.set(agentId, {
|
||||
kind: 'agent',
|
||||
launchUnconfirmed: true,
|
||||
notificationOwedAt: now
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +166,11 @@ export function oweClaudeShellTaskNotifications(
|
||||
): void {
|
||||
for (const [id, task] of tasks) {
|
||||
if (task.kind === 'shell' && !runningShellIds.has(id)) {
|
||||
task.notificationOwedAt ??= now
|
||||
if (task.listedOnly) {
|
||||
tasks.delete(id)
|
||||
} else {
|
||||
task.notificationOwedAt ??= now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,12 +267,22 @@ function claudeOwedTaskNotificationKinds(tasks: ClaudeLaunchedBackgroundTasks |
|
||||
const owed = { agent: false, shell: false }
|
||||
for (const task of tasks?.values() ?? []) {
|
||||
if (task.launchUnconfirmed !== true && task.notificationOwedAt !== undefined) {
|
||||
owed[task.kind] = true
|
||||
owed[task.awaitsAgents ? 'agent' : task.kind] = true
|
||||
}
|
||||
}
|
||||
return owed
|
||||
}
|
||||
|
||||
/** A recorded worker wait lives from launch until its notification is delivered, stopped or given up on. */
|
||||
export function claudeTasksAwaitAgents(tasks: ClaudeLaunchedBackgroundTasks | undefined): boolean {
|
||||
for (const task of tasks?.values() ?? []) {
|
||||
if (task.awaitsAgents) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Return owed kinds after expiring idle debt; undefined mainAgentIdleSince means this event is the idle boundary. */
|
||||
export function claudeLiveOwedTaskNotificationKinds(
|
||||
tasks: ClaudeLaunchedBackgroundTasks | undefined,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner'
|
||||
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
|
||||
import { orcaCliInvocations } from './orca-cli-invocations'
|
||||
|
||||
/** The browser CLI's command keys per handler group. Kept apart from the CLI's
|
||||
* lazy handler imports so the renderer and mobile can read them too. */
|
||||
@@ -103,34 +102,15 @@ const BROWSER_COMMAND_WORDS = new Set<string>(
|
||||
)
|
||||
)
|
||||
|
||||
// The published launcher names per platform, plus the dev build's.
|
||||
const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i
|
||||
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
|
||||
|
||||
export type OrcaBrowserCommand = { pageId: string | null }
|
||||
|
||||
/** The first `orca <browser command>` a shell command line runs, with the page it
|
||||
* targets, or null when it runs none. Quoted text is an argument, never a command. */
|
||||
export function parseOrcaBrowserCommand(command: string): OrcaBrowserCommand | null {
|
||||
const scanned = command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS)
|
||||
for (const segment of splitUnquotedSegments(scanned)) {
|
||||
const tokens = tokenizeCommandLine(segment)
|
||||
let index = 0
|
||||
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) {
|
||||
index += 1
|
||||
}
|
||||
const binary = tokens[index]
|
||||
const word = tokens[index + 1]
|
||||
if (
|
||||
binary !== undefined &&
|
||||
word !== undefined &&
|
||||
ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary)) &&
|
||||
BROWSER_COMMAND_WORDS.has(word)
|
||||
) {
|
||||
return { pageId: pageFlagValue(tokens.slice(index + 2)) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
const args = orcaCliInvocations(command).find((invocation) =>
|
||||
BROWSER_COMMAND_WORDS.has(invocation[0] ?? '')
|
||||
)
|
||||
return args ? { pageId: pageFlagValue(args.slice(1)) } : null
|
||||
}
|
||||
|
||||
function pageFlagValue(args: readonly string[]): string | null {
|
||||
@@ -145,25 +125,3 @@ function pageFlagValue(args: readonly string[]): string | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */
|
||||
function splitUnquotedSegments(commandLine: string): string[] {
|
||||
const segments: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
for (let index = 0; index < commandLine.length; index += 1) {
|
||||
const char = commandLine[index]
|
||||
if (quote === null && (char === '"' || char === "'")) {
|
||||
quote = char
|
||||
} else if (quote === char) {
|
||||
quote = null
|
||||
} else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) {
|
||||
segments.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += char
|
||||
}
|
||||
segments.push(current)
|
||||
return segments
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { COMMAND_TOKEN_SCAN_MAX_CHARS, getCommandTokenPathBasename } from './command-token-scanner'
|
||||
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
|
||||
|
||||
// The published launcher names per platform, plus the dev build's.
|
||||
const ORCA_CLI_BASENAME = /^orca(?:-dev|-ide)?(?:\.cmd|\.exe)?$/i
|
||||
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
|
||||
|
||||
/** The arguments of each `orca ...` a shell command line runs, in order. Quoted text is an argument, never a command. */
|
||||
export function orcaCliInvocations(command: string): string[][] {
|
||||
const invocations: string[][] = []
|
||||
for (const segment of splitUnquotedSegments(command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS))) {
|
||||
const tokens = tokenizeCommandLine(segment)
|
||||
let index = 0
|
||||
while (index < tokens.length && ENV_ASSIGNMENT.test(tokens[index])) {
|
||||
index += 1
|
||||
}
|
||||
const binary = tokens[index]
|
||||
if (binary !== undefined && ORCA_CLI_BASENAME.test(getCommandTokenPathBasename(binary))) {
|
||||
invocations.push(tokens.slice(index + 1))
|
||||
}
|
||||
}
|
||||
return invocations
|
||||
}
|
||||
|
||||
/** Canonical form a child row carries for a worker wait; raw commands outgrow the operation preview. */
|
||||
export const ORCA_ORCHESTRATION_WAIT_COMMAND = 'orca orchestration check --wait'
|
||||
|
||||
/** A command that blocks on `orca orchestration check --wait` is waiting on worker agents, not monitoring. */
|
||||
export function runsOrcaOrchestrationWait(command: unknown): boolean {
|
||||
return (
|
||||
typeof command === 'string' &&
|
||||
command.includes('orchestration') &&
|
||||
orcaCliInvocations(command).some(
|
||||
(args) => args[0] === 'orchestration' && args[1] === 'check' && args.includes('--wait')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Splits a command line at `&&`, `||`, `;`, `|` and newlines outside quotes. */
|
||||
function splitUnquotedSegments(commandLine: string): string[] {
|
||||
const segments: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
for (let index = 0; index < commandLine.length; index += 1) {
|
||||
const char = commandLine[index]
|
||||
if (quote === null && (char === '"' || char === "'")) {
|
||||
quote = char
|
||||
} else if (quote === char) {
|
||||
quote = null
|
||||
} else if (quote === null && (char === ';' || char === '|' || char === '&' || char === '\n')) {
|
||||
segments.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
current += char
|
||||
}
|
||||
segments.push(current)
|
||||
return segments
|
||||
}
|
||||
@@ -123,39 +123,47 @@ describe('createProgramStatusProjector', () => {
|
||||
expect(cleared.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps a finished turn working while a child runs, stamping the turn's own end for both rows", () => {
|
||||
it('keeps a finished turn working while a child runs, leaving it to the all-clear to announce', () => {
|
||||
const { feed, tick } = projector()
|
||||
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents'))
|
||||
tick(10)
|
||||
expect(feed(osc('state=done:app=pi'))).toMatchObject([
|
||||
{
|
||||
state: 'working',
|
||||
turnCompletedAt: 1010,
|
||||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 },
|
||||
subagents: [{ id: 'subagents/run1', state: 'working' }]
|
||||
}
|
||||
])
|
||||
const [held] = feed(osc('state=done:app=pi'))
|
||||
expect(held).toMatchObject({
|
||||
state: 'working',
|
||||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 },
|
||||
subagents: [{ id: 'subagents/run1', state: 'working' }]
|
||||
})
|
||||
expect(held.turnCompletedAt).toBeUndefined()
|
||||
tick(10)
|
||||
const [settled] = feed(osc('state=done:id=subagents/run1:app=pi-subagents'))
|
||||
expect(settled).toMatchObject({
|
||||
state: 'done',
|
||||
turnCompletedAt: 1010,
|
||||
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1010 }
|
||||
})
|
||||
expect(settled.turnCompletedAt).toBeUndefined()
|
||||
expect(settled.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it("lapses a hold no child refreshed to the lead's done, keeping its completion stamp", () => {
|
||||
it("stamps a failed turn's own end while a child still runs, for both rows", () => {
|
||||
const { feed, tick } = projector()
|
||||
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1:app=pi-subagents'))
|
||||
tick(10)
|
||||
expect(feed(osc('state=error:app=pi'))).toMatchObject([
|
||||
{ state: 'working', turnCompletedAt: 1010, mainAgent: { state: 'done', outcome: 'failure' } }
|
||||
])
|
||||
tick(10)
|
||||
expect(feed(osc('state=done:id=subagents/run1:app=pi-subagents'))).toMatchObject([
|
||||
{ state: 'done', turnCompletedAt: 1010 }
|
||||
])
|
||||
})
|
||||
|
||||
it("lapses a hold no child refreshed to the lead's done, keeping its verdict", () => {
|
||||
const { feed } = projector()
|
||||
feed(osc('state=working:app=pi') + osc('state=working:id=subagents/run1'))
|
||||
const [held] = feed(osc('state=done:app=pi'))
|
||||
expect(lapsedProgramStatusChildHold(held, 0, 999, 1000)).toBeNull()
|
||||
const lapsed = lapsedProgramStatusChildHold(held, 0, 1000, 1000)
|
||||
expect(lapsed).toMatchObject({
|
||||
state: 'done',
|
||||
turnCompletedAt: held.turnCompletedAt,
|
||||
mainAgent: held.mainAgent
|
||||
})
|
||||
expect(lapsed).toMatchObject({ state: 'done', mainAgent: held.mainAgent })
|
||||
expect(lapsed?.subagents).toBeUndefined()
|
||||
const [settled] = feed(osc('state=done:id=subagents/run1'))
|
||||
expect(lapsedProgramStatusChildHold(settled, 0, 1000, 1000)).toBeNull()
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status-
|
||||
import { AGENT_SUBAGENT_ID_MAX_LENGTH } from '../agent-status-subagent-snapshot'
|
||||
import type { AgentTurnOutcome } from '../agent-turn-outcome'
|
||||
import { foldMainAgentWithRowChildWork } from '../agent-hook-row-child-work-fold'
|
||||
import { isAgentStatusHeldOpenByChildWork } from '../agent-lead-status-fold'
|
||||
import {
|
||||
heldTurnCountsAsFinished,
|
||||
isAgentStatusHeldOpenByChildWork
|
||||
} from '../agent-lead-status-fold'
|
||||
import { createProgramStatusOscScanner } from './osc-scanner'
|
||||
import {
|
||||
programStatusDisplayText,
|
||||
@@ -210,13 +213,15 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog
|
||||
const msg = programStatusDisplayText(root?.msg)
|
||||
const subagents = snapshotChildren()
|
||||
// Why: async children outlive a finished turn (pi-subagents); failed/idle children and boundaries hold nothing.
|
||||
const state =
|
||||
const held =
|
||||
projection.state === 'done' && projection.sessionBoundary !== true
|
||||
? foldMainAgentWithRowChildWork('done', {
|
||||
payload: { subagents: subagents.filter(isRowHoldingChild) }
|
||||
}).stateName
|
||||
: projection.state
|
||||
rootDoneHeldOpen ||= projection.state === 'done' && state !== 'done'
|
||||
})
|
||||
: null
|
||||
const state = held?.stateName ?? projection.state
|
||||
rootDoneHeldOpen ||=
|
||||
held !== null && state !== 'done' && heldTurnCountsAsFinished(held, projection.outcome, false)
|
||||
const payload: ParsedAgentStatusPayload = {
|
||||
state,
|
||||
prompt: '',
|
||||
@@ -228,7 +233,7 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog
|
||||
: {}),
|
||||
...(projection.sessionBoundary ? { sessionBoundary: true } : {}),
|
||||
...(subagents.length > 0 ? { subagents } : {}),
|
||||
// Hook rows announce a held turn's own done now; the all-clear restates it so it is not announced twice.
|
||||
// A held turn that counts as finished is announced now; the all-clear restates it so it is not announced twice.
|
||||
...(rootDoneHeldOpen ? { turnCompletedAt: rootStateStartedAt } : {}),
|
||||
mainAgent: {
|
||||
state: projection.state,
|
||||
|
||||
@@ -141,7 +141,8 @@ async function framesFor(
|
||||
const feed = new host.StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions,
|
||||
now: () => 10,
|
||||
readStatusState: () => host.projectStructuredAgentSessionStatusState(items, [])
|
||||
readStatusState: () => host.projectStructuredAgentSessionStatusState(items, []),
|
||||
readChildWork: () => undefined
|
||||
})
|
||||
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
|
||||
hostCalls.subscribeTurnCompletions = vi.fn((subscriber: unknown) => feed.subscribe(subscriber))
|
||||
|
||||
@@ -112,6 +112,7 @@ function attentionPath(directory: string) {
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(items),
|
||||
readChildWork: () => undefined,
|
||||
now: () => 42
|
||||
})
|
||||
feed.subscribe({
|
||||
|
||||
@@ -228,6 +228,7 @@ beforeEach(() => {
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(fixture.items),
|
||||
readChildWork: () => undefined,
|
||||
now: hostClock
|
||||
})
|
||||
fixture = {
|
||||
|
||||
@@ -179,6 +179,7 @@ describe('Explicit desktop read acknowledgements', () => {
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(otherItems),
|
||||
readChildWork: () => undefined,
|
||||
now: () => 42
|
||||
})
|
||||
const delivery = createStructuredAttentionMobileDelivery({
|
||||
|
||||
Reference in New Issue
Block a user