Files
orca/src/shared/claude-background-task-inventory.ts
T
NeilandOrca a20d82294b fix(agent-status): preserve Claude background work (#11838)
* fix(agent-status): preserve Claude background work

* fix(agent-status): harden background task lifecycle

* fix(agent-status): narrow interruption retention

* fix(agent-status): scope background task authority

* fix(agent-status): isolate lifecycle inventories

* fix(agent-status): harden background evidence recovery

* fix(agent-status): reject ambiguous child authority

* perf(agent-status): skip lifecycle inventory scans

* refactor(agent-status): isolate task inventory parsing

* fix(agent-status): clear stale background evidence

* fix(agent-status): gate accepted remote evidence

* test(agent-status): pin session cron interrupts

* fix: harden Claude inventory tracking

* test: pin Claude cron drain authority

* refactor(agent-status): unify Claude turn-boundary predicate

Collapse the five inline copies of the Stop/StopFailure test into a single
isTurnBoundary constant and drop the reportedStateName/stateName alias, so a
future edit can't move one copy and leave the others behind.

Pin the two behaviors that unification now depends on: a non-interrupted
StopFailure keeps gating on live background work, and interrupted state does
not survive a mid-turn lead event that has no prompt submit.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): gate local Claude background evidence

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-01 21:10:08 -07:00

98 lines
3.2 KiB
TypeScript

import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
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
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
return { present: false, tasks: [], truncated: false, hasRunningNonAgentTask: false }
}
const tasks: ClaudeBackgroundAgentTask[] = []
let truncated = false
let hasRunningNonAgentTask = false
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 = taskType === 'subagent' || taskType === 'teammate'
// 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
}
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 }
}