Files
orca/src/shared/claude-background-task-inventory.ts
T
Kelvin AmoabaandNeil 634c43787c Fix premature Claude automation completion and inherited CI failures (#24878)
* fix(agent-status): keep a Claude pane working until owed task wake-ups arrive

Claude wakes the main agent for each background task that ends, after the
task stops running. "Nothing running" was read as done, so automations
closed the terminal before the final turn.

Fixes #23942

* fix(agent-status): keep owed task wake-ups through failed turns, the cap and nested exits

A failed turn's task list now marks a vanished shell owed like a normal turn end does. Past
the cap only an already-announced sub-agent is forgotten. A process exit clears what is owed
only where the server admits it from the pane's owner.

* refactor(agent-status): move pane-scoped cache entry helpers out of listener-state

listener-state.ts passed the 300-line limit once main's and this branch's additions met.

* fix(agent-status): verify Claude task wake-up completion on its execution host

* fix(agent-status): canonicalize Claude background task identifiers

* Preserve out-of-order Claude task notification evidence

* Verify retained released parser in cross-version checkout test

* Reuse watcher directory-cache enumeration without losing fresh keys

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-06 01:02:18 -07:00

113 lines
3.8 KiB
TypeScript

import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
import { classifyClaudeBackgroundTaskKind } from './claude-background-task-kind'
import { isAgentChildWorkKind } from './agent-status-child-work-liveness'
const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
'idle',
'done',
'success',
'succeeded',
'complete',
'completed',
'finished',
'failed',
'error',
'terminated',
'exited',
'aborted',
'expired',
'skipped',
'crashed',
'killed',
'cancelled',
'canceled',
'timed_out'
])
/** One agent entry from the `background_tasks` array Claude attaches to Stop
* (and SubagentStop) hook payloads. Non-agent tasks do not become rows. */
export type ClaudeBackgroundAgentTask = {
id: string
agentType?: string
description?: string
running: boolean
/** True for `type: "teammate"` entries. Their ids never match lifecycle
* agent_ids and they report "running" permanently — even after the named
* agent finished — so they carry no per-agent state at all. */
teammate: boolean
}
/** Read the agent-typed entries of a hook payload's `background_tasks` field.
* `present: false` means the field was absent/malformed (older Claude builds),
* so callers must keep their tracked roster instead of clearing it. */
export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unknown>): {
present: boolean
tasks: ClaudeBackgroundAgentTask[]
truncated: boolean
hasRunningNonAgentTask: boolean
/** Ids of the running shell-like entries; one without an id is counted but not listed. */
runningNonAgentTaskIds: string[]
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
return {
present: false,
tasks: [],
truncated: false,
hasRunningNonAgentTask: false,
runningNonAgentTaskIds: []
}
}
const tasks: ClaudeBackgroundAgentTask[] = []
let truncated = false
let hasRunningNonAgentTask = false
const runningNonAgentTaskIds: string[] = []
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
truncated = true
hasRunningNonAgentTask = true
continue
}
const obj = item as Record<string, unknown>
const taskType = typeof obj.type === 'string' ? obj.type.trim().toLowerCase() : ''
const taskStatus = typeof obj.status === 'string' ? obj.status.trim().toLowerCase() : ''
const isTerminal =
taskStatus.length > 0 && CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES.has(taskStatus)
if (taskType.length === 0) {
truncated = true
hasRunningNonAgentTask ||= !isTerminal
continue
}
const isAgentTask = isAgentChildWorkKind(classifyClaudeBackgroundTaskKind(taskType))
// Why: future non-agent types and nonterminal labels must fail active; only typed agent rows or explicit terminal states can safely retire work.
if (!isAgentTask && !isTerminal) {
hasRunningNonAgentTask = true
const id = typeof obj.id === 'string' ? obj.id.trim() : ''
if (id.length > 0) {
runningNonAgentTaskIds.push(id)
}
}
if (!isAgentTask) {
continue
}
if (typeof obj.id !== 'string' || obj.id.trim().length === 0) {
truncated = true
continue
}
if (tasks.length >= AGENT_STATUS_MAX_SUBAGENTS) {
// Why: a capped inventory cannot prove a tracked id is absent; callers
// must retain unlisted rows rather than deleting live overflow tasks.
truncated = true
continue
}
tasks.push({
id: obj.id.trim(),
agentType: typeof obj.agent_type === 'string' ? obj.agent_type : undefined,
description: typeof obj.description === 'string' ? obj.description : undefined,
running: !isTerminal,
teammate: taskType === 'teammate'
})
}
return { present: true, tasks, truncated, hasRunningNonAgentTask, runningNonAgentTaskIds }
}