fix(agent-status): require owned Claude completion evidence

This commit is contained in:
OrcaWin
2026-08-20 03:22:23 -07:00
parent a426050f45
commit 621b34c424
8 changed files with 281 additions and 53 deletions
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, _internals } from './server'
import { AGENT_STATUS_MAX_FIELD_LENGTH } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
@@ -130,6 +133,76 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not retire Claude subagents from a rejected idle status', async () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-rejected-idle-'))
const transcriptPath = join(dir, 'session.jsonl')
writeFileSync(transcriptPath, '')
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = async (
source: 'codex' | 'claude',
payload: Record<string, unknown>
): Promise<void> => {
const response = await fetch(
`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody(payload))
}
)
expect(response.status).toBe(204)
}
await postHook('codex', {
hook_event_name: 'UserPromptSubmit',
prompt: 'parent codex'
})
const parent = server.getStatusSnapshot()[0]
const state = server._getStateForTests()
state.claudeSubagentRosterByPaneKey.set(
PANE,
new Map([['agent-1', { state: 'working', startedAt: 1 }]])
)
state.claudeBackgroundTaskObservationsByPaneKey.set(
PANE,
new Map([['agent-1', { transcriptPath, transcriptByteOffset: 0, nonAgent: false }]])
)
appendFileSync(
transcriptPath,
`${JSON.stringify({
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
message: {
content:
'<task-notification>\n<task-id>agent-1</task-id>\n<status>completed</status>\n</task-notification>'
}
})}\n`
)
await postHook('claude', {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})
expect(server.getStatusSnapshot()[0]).toEqual(parent)
expect(
server._getStateForTests().claudeSubagentRosterByPaneKey.get(PANE)?.has('agent-1')
).toBe(true)
expect(server._getStateForTests().claudeLeadStateByPaneKey.has(PANE)).toBe(false)
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
+50 -3
View File
@@ -39,6 +39,7 @@ import {
warnOnHookEnvOrVersionMismatch,
writeEndpointFile,
type AgentHookEventPayload,
type ClaudeLeadTurnState,
type HookListenerState
} from '../../shared/agent-hook-listener'
import {
@@ -51,7 +52,8 @@ import {
claudeTeammateIdMatchesName,
claudeRosterHasRestoredSnapshotSubagent,
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
claudeRosterToSnapshots,
type ClaudeSubagentRoster
} from '../../shared/claude-subagent-roster'
import {
isAgentHookSource,
@@ -120,6 +122,12 @@ type NormalizedLocalHook = {
onAccepted?: () => void
}
function cloneClaudeSubagentRoster(
roster: ClaudeSubagentRoster | undefined
): ClaudeSubagentRoster | undefined {
return roster ? new Map(Array.from(roster, ([id, tracked]) => [id, { ...tracked }])) : undefined
}
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
| 'claudeRunningNonAgentTask'
@@ -2043,6 +2051,10 @@ export class AgentHookServer {
const previousUnidentifiedTask =
this.state.claudeUnidentifiedRunningNonAgentTaskPaneKeys.has(paneKey)
const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
const previousSubagentRoster = cloneClaudeSubagentRoster(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
)
const previousLeadState = this.cloneClaudeLeadState(paneKey)
const event = normalizeHookPayload(this.state, source, body, this.env)
const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const nextTaskObservations = new Map(
@@ -2051,6 +2063,10 @@ export class AgentHookServer {
const nextUnidentifiedTask =
this.state.claudeUnidentifiedRunningNonAgentTaskPaneKeys.has(paneKey)
const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
const nextSubagentRoster = cloneClaudeSubagentRoster(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
)
const nextLeadState = this.cloneClaudeLeadState(paneKey)
this.setClaudeBackgroundEvidence(
paneKey,
previousRunningTask,
@@ -2058,13 +2074,15 @@ export class AgentHookServer {
previousUnidentifiedTask,
previousActiveCron
)
this.setClaudeSubagentRoster(paneKey, previousSubagentRoster)
this.setClaudeLeadState(paneKey, previousLeadState)
if (!event || event.paneKey !== paneKey) {
return { event }
}
// Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate.
// Why: nested CLIs may inherit the pane key; only accepted statuses may mutate Claude lifecycle evidence.
return {
event,
onAccepted: () =>
onAccepted: () => {
this.setClaudeBackgroundEvidence(
paneKey,
nextRunningTask,
@@ -2072,6 +2090,35 @@ export class AgentHookServer {
nextUnidentifiedTask,
nextActiveCron
)
this.setClaudeSubagentRoster(paneKey, nextSubagentRoster)
this.setClaudeLeadState(paneKey, nextLeadState)
}
}
}
private setClaudeSubagentRoster(paneKey: string, roster: ClaudeSubagentRoster | undefined): void {
if (roster) {
this.state.claudeSubagentRosterByPaneKey.set(paneKey, roster)
} else {
this.state.claudeSubagentRosterByPaneKey.delete(paneKey)
}
}
private cloneClaudeLeadState(paneKey: string): ClaudeLeadTurnState | undefined {
const lead = this.state.claudeLeadStateByPaneKey.get(paneKey)
return lead
? {
...lead,
...(lead.stateBeforeWait ? { stateBeforeWait: { ...lead.stateBeforeWait } } : {})
}
: undefined
}
private setClaudeLeadState(paneKey: string, lead: ClaudeLeadTurnState | undefined): void {
if (lead) {
this.state.claudeLeadStateByPaneKey.set(paneKey, lead)
} else {
this.state.claudeLeadStateByPaneKey.delete(paneKey)
}
}
@@ -144,6 +144,8 @@ describe('listClaudeSubagentSessions', () => {
{
// Interim notification: superseded by the terminal one below.
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:30.000Z',
message: {
@@ -154,6 +156,8 @@ describe('listClaudeSubagentSessions', () => {
},
{
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
sessionId: 'parent-session',
timestamp: '2026-07-05T10:01:00.000Z',
message: {
@@ -163,13 +167,16 @@ describe('listClaudeSubagentSessions', () => {
}
},
{
// queue-operation records carry the notification as a plain string.
type: 'queue-operation',
operation: 'enqueue',
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
sessionId: 'parent-session',
timestamp: '2026-07-05T10:02:00.000Z',
content:
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
message: {
role: 'user',
content:
'<task-notification>\n<task-id>crashed</task-id>\n<status>failed</status>\n</task-notification>'
}
},
{
// Synchronous Tasks finish with a toolUseResult record, no notification.
@@ -240,6 +247,8 @@ describe('listClaudeSubagentSessions', () => {
},
{
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
sessionId: 'parent-session',
timestamp: '2026-07-05T10:01:00.000Z',
message: { role: 'user', content: notification }
@@ -269,6 +278,8 @@ describe('listClaudeSubagentSessions', () => {
await writeJsonlFile(parentFilePath, [
{
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
sessionId: 'parent-session',
timestamp: '2026-07-05T10:00:00.000Z',
message: {
+7 -3
View File
@@ -133,9 +133,13 @@ describe('RelayAgentHookServer', () => {
appendFileSync(
transcriptPath,
`${JSON.stringify({
type: 'queue-operation',
content:
'<task-notification>\n<task-id>shell-1</task-id>\n<status>completed</status>\n</task-notification>'
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
message: {
content:
'<task-notification>\n<task-id>shell-1</task-id>\n<status>completed</status>\n</task-notification>'
}
})}\n`
)
await post({ hook_event_name: 'PreToolUse', tool_name: 'Bash' })
+14 -20
View File
@@ -2670,7 +2670,6 @@ function updateClaudeBackgroundTaskEvidence(
transcriptByteOffset = null
}
}
const previous = state.claudeBackgroundTaskObservationsByPaneKey.get(paneKey)
const observations = new Map<
string,
{ transcriptPath: string; transcriptByteOffset: number; nonAgent: boolean }
@@ -2684,14 +2683,7 @@ function updateClaudeBackgroundTaskEvidence(
.map((task) => ({ id: task.id, nonAgent: false }))
]
for (const task of runningTasks) {
const existing = previous?.get(task.id)
if (
existing &&
existing.transcriptPath === transcriptPath &&
existing.nonAgent === task.nonAgent
) {
observations.set(task.id, existing)
} else if (transcriptPath && transcriptByteOffset !== null) {
if (transcriptPath && transcriptByteOffset !== null) {
observations.set(task.id, { transcriptPath, transcriptByteOffset, nonAgent: task.nonAgent })
} else if (task.nonAgent) {
hasUnidentified = true
@@ -2720,11 +2712,11 @@ function reconcileClaudeIdlePromptTasks(
state: HookListenerState,
paneKey: string,
hookPayload: Record<string, unknown>
): void {
): boolean {
const transcriptPath = readString(hookPayload, 'transcript_path')
const observations = state.claudeBackgroundTaskObservationsByPaneKey.get(paneKey)
if (!transcriptPath || !observations) {
return
return false
}
const matchingObservations = Array.from(observations.entries()).filter(
([, observation]) => observation.transcriptPath === transcriptPath
@@ -2733,7 +2725,7 @@ function reconcileClaudeIdlePromptTasks(
...matchingObservations.map(([, observation]) => observation.transcriptByteOffset)
)
if (!Number.isFinite(minimumByteOffset)) {
return
return false
}
const notifications = readClaudeTerminalTaskNotifications(transcriptPath, minimumByteOffset)
const terminalTaskIds = new Set<string>()
@@ -2750,7 +2742,7 @@ function reconcileClaudeIdlePromptTasks(
}
}
if (terminalTaskIds.size === 0) {
return
return false
}
if (observations.size === 0) {
state.claudeBackgroundTaskObservationsByPaneKey.delete(paneKey)
@@ -2766,7 +2758,7 @@ function reconcileClaudeIdlePromptTasks(
}
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
if (!roster) {
return
return true
}
for (const taskId of terminalTaskIds) {
stopClaudeSubagent(roster, taskId)
@@ -2774,6 +2766,7 @@ function reconcileClaudeIdlePromptTasks(
if (roster.size === 0) {
state.claudeSubagentRosterByPaneKey.delete(paneKey)
}
return true
}
function resolveClaudePaneState(
@@ -3097,6 +3090,10 @@ function normalizeClaudeEvent(
const previousLead = state.claudeLeadStateByPaneKey.get(paneKey)
const isIdlePromptNotification =
eventName === 'Notification' && hookPayload['notification_type'] === 'idle_prompt'
const reconciledIdlePrompt =
isIdlePromptNotification && eventAgentId === undefined
? reconcileClaudeIdlePromptTasks(state, paneKey, hookPayload)
: false
// Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it.
const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure'
const interrupted =
@@ -3134,7 +3131,7 @@ function normalizeClaudeEvent(
: eventName === 'PermissionRequest' || isAskUserQuestion
? 'waiting'
: isTurnBoundary ||
isIdlePromptNotification ||
reconciledIdlePrompt ||
(eventName === 'PostCompact' && hookPayload.trigger === 'manual')
? 'done'
: null
@@ -3142,9 +3139,6 @@ function normalizeClaudeEvent(
if (!reportedStateName) {
return null
}
if (isIdlePromptNotification && eventAgentId === undefined) {
reconcileClaudeIdlePromptTasks(state, paneKey, hookPayload)
}
if (backgroundTasks.present && eventAgentId === undefined) {
updateClaudeBackgroundTaskEvidence(
state,
@@ -3278,12 +3272,12 @@ function normalizeClaudeEvent(
// Why: the lead already ended — the pane stays `working` only because background inventory is still registered. `stateStartedAt` is pinned for that whole run, so this end time is the per-turn identity and the later all-clear's pair key.
const turnCompletedAt =
eventAgentId === undefined &&
(isTurnBoundary || isIdlePromptNotification) &&
(isTurnBoundary || reconciledIdlePrompt) &&
reportedStateName === 'done' &&
interrupted !== true
? effectiveState === 'working'
? (previousLead?.turnCompletedAt ?? Date.now())
: isIdlePromptNotification
: reconciledIdlePrompt
? previousLead?.turnCompletedAt
: undefined
: undefined
@@ -32,10 +32,14 @@ function claudeEvent(state: HookListenerState, paneKey: string, payload: Record<
function taskNotificationLine(taskId: string, status = 'completed'): string {
return `${JSON.stringify({
type: 'queue-operation',
content:
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
`<status>${status}</status>\n</task-notification>`
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
message: {
content:
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
`<status>${status}</status>\n</task-notification>`
}
})}\n`
}
@@ -186,8 +190,9 @@ describe('Claude background task status', () => {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})?.state
).toBe('working')
})
).toBeUndefined()
expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('done')
appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id))
expect(
@@ -195,8 +200,9 @@ describe('Claude background task status', () => {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})?.state
).toBe('working')
})
).toMatchObject({ state: 'working' })
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
appendFileSync(transcriptPath, taskNotificationLine('shell-2', 'failed'))
expect(
@@ -228,8 +234,9 @@ describe('Claude background task status', () => {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})?.state
).toBe('working')
})
).toBeUndefined()
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
@@ -251,8 +258,9 @@ describe('Claude background task status', () => {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})?.state
).toBe('working')
})
).toBeUndefined()
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id))
expect(
@@ -267,6 +275,46 @@ describe('Claude background task status', () => {
}
})
it('refreshes ownership when Claude reports a reused task id as running', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-idle-reused-task-'))
const transcriptPath = join(dir, 'session.jsonl')
writeFileSync(transcriptPath, '')
try {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
transcript_path: transcriptPath,
background_tasks: [RUNNING_SHELL]
})
appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id))
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
transcript_path: transcriptPath,
background_tasks: [RUNNING_SHELL]
})
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})
).toBeUndefined()
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id))
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: transcriptPath
})
).toMatchObject({ state: 'done' })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('retires a background subagent whose SubagentStop was missed', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-idle-subagent-'))
const transcriptPath = join(dir, 'session.jsonl')
@@ -306,6 +354,7 @@ describe('Claude background task status', () => {
background_tasks: [RUNNING_SHELL],
session_crons: [{ id: 'cron-1' }]
})
appendFileSync(transcriptPath, taskNotificationLine(RUNNING_SHELL.id))
expect(
claudeEvent(state, SOURCE_PANE, {
@@ -333,6 +382,21 @@ describe('Claude background task status', () => {
expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('working')
})
it('does not treat idle_prompt alone as task completion evidence', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'run it' })
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'PreToolUse', tool_name: 'Bash' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
transcript_path: 'unowned-session.jsonl'
})
).toBeUndefined()
expect(state.claudeLeadStateByPaneKey.get(SOURCE_PANE)?.state).toBe('working')
})
it('keeps pending task state when pane authority moves', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {
+31 -7
View File
@@ -10,15 +10,20 @@ import {
function notificationRecord(taskId: string, status: string): string {
return JSON.stringify({
type: 'queue-operation',
content:
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
`<status>${status}</status>\n</task-notification>`
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
message: {
role: 'user',
content:
`<task-notification>\n<task-id>${taskId}</task-id>\n` +
`<status>${status}</status>\n</task-notification>`
}
})
}
describe('Claude task notifications', () => {
it('parses queue and user delivery shapes but not quoted prose', () => {
it('parses provider task delivery but not queue or user-authored shapes', () => {
expect(parseClaudeTaskNotificationLine(notificationRecord('task-1', 'completed'))).toEqual({
taskId: 'task-1',
status: 'completed'
@@ -27,6 +32,8 @@ describe('Claude task notifications', () => {
parseClaudeTaskNotificationLine(
JSON.stringify({
type: 'user',
promptSource: 'system',
origin: { kind: 'task-notification' },
message: {
content: [
{
@@ -38,13 +45,29 @@ describe('Claude task notifications', () => {
})
)
).toEqual({ taskId: 'task-2', status: 'failed' })
expect(
parseClaudeTaskNotificationLine(
JSON.stringify({
type: 'queue-operation',
operation: 'enqueue',
content:
'<task-notification><task-id>task-2</task-id><status>failed</status></task-notification>'
})
)
).toBeNull()
expect(
parseClaudeTaskNotificationLine(
notificationRecord('task-4', 'completed').replace('</task-notification>', '')
)
).toBeNull()
expect(
parseClaudeTaskNotificationLine(
JSON.stringify({
type: 'user',
promptSource: 'user',
message: {
content:
'Explain <task-notification><task-id>task-3</task-id><status>completed</status></task-notification>'
'<task-notification><task-id>task-3</task-id><status>completed</status></task-notification>'
}
})
)
@@ -74,7 +97,8 @@ describe('Claude task notifications', () => {
it('skips a partial JSONL record at the ownership offset', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-task-offset-'))
const transcriptPath = join(dir, 'session.jsonl')
const partialRecord = '{"type":"user","message":{"content":"'
const partialRecord =
'{"type":"user","promptSource":"system","origin":{"kind":"task-notification"},"message":{"content":"'
writeFileSync(transcriptPath, partialRecord)
try {
appendFileSync(
+14 -3
View File
@@ -86,7 +86,10 @@ export function parseClaudeTaskNotificationLine(
line: string
): { taskId: string; status: string } | null {
const notification = taskNotificationText(line)
if (!notification.startsWith(CLAUDE_TASK_NOTIFICATION_MARKER)) {
if (
!notification.startsWith(CLAUDE_TASK_NOTIFICATION_MARKER) ||
!notification.endsWith('</task-notification>')
) {
return null
}
const taskId = TASK_ID_PATTERN.exec(notification)?.[1]?.trim()
@@ -101,8 +104,16 @@ function taskNotificationText(line: string): string {
return ''
}
const record = parsed as Record<string, unknown>
if (typeof record.content === 'string') {
return record.content.trim()
const origin =
record.origin && typeof record.origin === 'object'
? (record.origin as Record<string, unknown>)
: undefined
if (
record.type !== 'user' ||
record.promptSource !== 'system' ||
origin?.kind !== 'task-notification'
) {
return ''
}
if (!record.message || typeof record.message !== 'object') {
return ''