fix(agent-status): only a recognised background shell may stop gating a finished lead

STA-4119's first pass dropped the whole background-inventory disjunct, which also
retired panes whose running entry Orca could not classify — a `type: "agent"` row, a
future type, a blank or malformed entry. readClaudeBackgroundAgentTasks documents that
those must fail active, and that gate was its only enforcement.

Split the signal instead. A positively identified shell (`shell`, `background_shell`)
stops gating pane state; everything else stays unclassified and still holds the lead at
working. Live child agents and session crons are unchanged.

The broad "provider still owns live background work" signal is kept and now reaches the
renderer, because a pane that can finally reach done also became eligible for agent
hibernation — which kills the PTY, and the user's dev server with it.

Interrupt inference now keys off the unclassified signal too: the shell set survives
into the next turn, so keeping it there only blocked inference on a real turn the user
had just cancelled.

STA-4119
This commit is contained in:
Brennan Benson
2026-08-17 17:32:38 -07:00
parent 6465073125
commit af7eefe8fd
19 changed files with 486 additions and 28 deletions
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { buildBody, PANE, RUNNING_SHELL } from './server.test-fixtures'
import { buildBody, PANE } from './server.test-fixtures'
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
getCohortAtEmitMock: vi.fn(),
@@ -222,12 +222,20 @@ describe('AgentHookServer listener replay', () => {
await expect(
postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' })
).resolves.toMatchObject({ status: 204 })
// Why: a recognised shell now retires the pane to 'done', which short-circuits
// inference before this guard runs — so it must be unclassifiable work that keeps
// the pane 'working' here, or the assertion below proves nothing (STA-4119).
await expect(
postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] })
postHook({
hook_event_name: 'Stop',
background_tasks: [{ id: 'task-1', type: 'workflow', status: 'running' }]
})
).resolves.toMatchObject({ status: 204 })
const baseline = server.getStatusSnapshot()[0]
expect(baseline).toMatchObject({ state: 'working' })
expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask')
expect(baseline).not.toHaveProperty('claudeUnclassifiedBackgroundTask')
expect(
server.inferInterrupt({
paneKey: PANE,
@@ -263,6 +271,88 @@ describe('AgentHookServer listener replay', () => {
}
})
// Why: STA-4119. The set survives into the NEXT turn, so while a recognised shell was
// still in it this guard could only fire on a genuinely active turn the user just
// cancelled — the opposite of what its comment promises. Only unclassifiable work
// (which can still hold an idle pane at 'working') may block inference now.
it('lets a recognised background shell be interrupted but not unclassifiable work', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const ingest = (envelope: Record<string, unknown>): void => {
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
...envelope,
payload: { state: 'working', prompt: 'second turn', agentType: 'claude' }
} as Parameters<AgentHookServer['ingestRemote']>[0],
'conn-1'
)
}
const infer = (): boolean => {
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(Number(baseline.receivedAt) + 500)
return server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'second turn',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
}
ingest({ claudeRunningNonAgentTask: true, claudeUnclassifiedBackgroundTask: true })
expect(infer()).toBe(false)
vi.setSystemTime(3_000)
ingest({ claudeRunningNonAgentTask: true, claudeUnclassifiedBackgroundTask: false })
expect(infer()).toBe(true)
} finally {
vi.useRealTimers()
}
})
// Why: a host older than STA-4119 sends only the broad field, and there it gated pane
// state. Reading it as the fail-active signal reproduces that host exactly.
it('falls back to the legacy field when an older host omits the unclassified flag', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
claudeRunningNonAgentTask: true,
payload: { state: 'working', prompt: 'legacy host', agentType: 'claude' }
},
'conn-1'
)
expect(server._getStateForTests().claudeUnclassifiedBackgroundTaskPaneKeys.has(PANE)).toBe(
true
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'legacy host',
baselineAgentType: 'claude',
intent: 'plain-escape'
})
).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('uses replayed Claude background metadata only before a live observation', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -84,7 +84,11 @@ describe('Last-status persistence', () => {
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
)
expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined()
expect(file.entries[PANE].claudeUnclassifiedBackgroundTask).toBeUndefined()
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask')
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain(
'claudeUnclassifiedBackgroundTask'
)
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
} finally {
server.stop()
+50 -11
View File
@@ -106,6 +106,12 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
claudeLeadBoundaryChildOnly?: true
}
type ClaudeBackgroundEvidence = {
hasRunningTask: boolean
hasUnclassifiedTask: boolean
hasActiveCron: boolean
}
type NormalizedLocalHook = {
event: AgentHookEventPayload | null
onAccepted?: () => void
@@ -113,7 +119,11 @@ type NormalizedLocalHook = {
type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed'
| 'claudeRunningNonAgentTask'
| 'claudeUnclassifiedBackgroundTask'
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
> & {
launchTokenHash?: string
}
@@ -934,10 +944,13 @@ export class AgentHookServer {
if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) {
return false
}
// Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons.
// Why: Escape/Ctrl+C does not stop unclassified background work or session crons, and
// those are the only inventory that can still hold an idle pane at 'working'. A recognised
// shell no longer gates, so keeping it here would only block inference on a REAL turn the
// user just cancelled — the set survives into the next turn (STA-4119).
if (
agentType === 'claude' &&
(this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) ||
(this.state.claudeUnclassifiedBackgroundTaskPaneKeys.has(existing.paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))
) {
return false
@@ -1982,32 +1995,42 @@ export class AgentHookServer {
if (!paneKey) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
}
const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
const previous = this.readClaudeBackgroundEvidence(paneKey)
const event = normalizeHookPayload(this.state, source, body, this.env)
const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron)
const next = this.readClaudeBackgroundEvidence(paneKey)
this.setClaudeBackgroundEvidence(paneKey, previous)
if (!event || event.paneKey !== paneKey) {
return { event }
}
// Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate.
return {
event,
onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron)
onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, next)
}
}
private readClaudeBackgroundEvidence(paneKey: string): ClaudeBackgroundEvidence {
return {
hasRunningTask: this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey),
hasUnclassifiedTask: this.state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey),
hasActiveCron: this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
}
}
private setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
hasActiveCron: boolean
{ hasRunningTask, hasUnclassifiedTask, hasActiveCron }: ClaudeBackgroundEvidence
): void {
if (hasRunningTask) {
this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey)
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
if (hasUnclassifiedTask) {
this.state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey)
} else {
this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
}
if (hasActiveCron) {
this.state.claudeActiveSessionCronPaneKeys.add(paneKey)
} else {
@@ -2130,6 +2153,7 @@ export class AgentHookServer {
/** Payload fields the relay dropped to fit an oversized frame; validated below. */
shedFields?: unknown
claudeRunningNonAgentTask?: unknown
claudeUnclassifiedBackgroundTask?: unknown
payload: unknown
},
connectionId: string
@@ -2279,6 +2303,12 @@ export class AgentHookServer {
) {
return
}
// Why: a pre-STA-4119 host sends only the broad field. It gated pane state there, so
// reading it as the fail-active signal reproduces that host's behaviour exactly.
const unclassifiedBackgroundTask =
typeof envelope.claudeUnclassifiedBackgroundTask === 'boolean'
? envelope.claudeUnclassifiedBackgroundTask
: envelope.claudeRunningNonAgentTask
const applyClaudeBackgroundWork =
normalizedPayload.agentType === 'claude' &&
typeof envelope.claudeRunningNonAgentTask === 'boolean' &&
@@ -2313,6 +2343,8 @@ export class AgentHookServer {
typeof envelope.claudeRunningNonAgentTask === 'boolean'
? envelope.claudeRunningNonAgentTask
: undefined,
claudeUnclassifiedBackgroundTask:
typeof unclassifiedBackgroundTask === 'boolean' ? unclassifiedBackgroundTask : undefined,
payload: normalizedPayload
}
this.recordCurrentAuthorityObservation(event)
@@ -2325,6 +2357,11 @@ export class AgentHookServer {
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
if (unclassifiedBackgroundTask) {
this.state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey)
} else {
this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
}
}
: undefined
)
@@ -2551,6 +2588,7 @@ export class AgentHookServer {
this.state.claudeSubagentRosterByPaneKey.delete(paneKey)
this.state.claudeLeadStateByPaneKey.delete(paneKey)
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
this.state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
}
@@ -3050,6 +3088,7 @@ export class AgentHookServer {
const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
claudeUnclassifiedBackgroundTask: _claudeUnclassifiedBackgroundTask,
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
+4
View File
@@ -1582,6 +1582,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
providerSessionOnly,
promptInteractionKey,
restoredUnconfirmed,
claudeRunningNonAgentTask,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
@@ -1630,6 +1631,9 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: the hibernation planner needs "is provider background work still live", which
// is not derivable from `state` once a leftover shell stops gating it (STA-4119).
...(claudeRunningNonAgentTask ? { providerBackgroundWorkActive: true } : {}),
...(orchestration ? { orchestration } : {})
}
mainWindow?.webContents.send('agentStatus:set', statusEvent)
+5
View File
@@ -1509,6 +1509,7 @@ export class SshRelaySession {
providerSessionOnly?: unknown
shedFields?: unknown
claudeRunningNonAgentTask?: unknown
claudeUnclassifiedBackgroundTask?: unknown
payload?: unknown
}
if (typeof envelope.paneKey !== 'string') {
@@ -1548,6 +1549,10 @@ export class SshRelaySession {
typeof envelope.claudeRunningNonAgentTask === 'boolean'
? envelope.claudeRunningNonAgentTask
: undefined,
claudeUnclassifiedBackgroundTask:
typeof envelope.claudeUnclassifiedBackgroundTask === 'boolean'
? envelope.claudeUnclassifiedBackgroundTask
: undefined,
payload: envelope.payload
},
this.targetId
+1
View File
@@ -31,6 +31,7 @@ export function buildRelayHookEnvelope(
teammateName: event.teammateName,
toolAgentType: event.toolAgentType,
claudeRunningNonAgentTask: event.claudeRunningNonAgentTask,
claudeUnclassifiedBackgroundTask: event.claudeUnclassifiedBackgroundTask,
...(event.providerSession ? { providerSession: event.providerSession } : {}),
...(event.providerSessionOnly ? { providerSessionOnly: true } : {}),
isReplay: options.isReplay === true ? true : undefined,
+24
View File
@@ -97,10 +97,34 @@ describe('RelayAgentHookServer', () => {
})
})
// Why: the host still reports live provider work (hibernation reads it) while the
// fail-active gate stays clear, so the pane is free to settle to 'done'.
expect(forward.mock.calls[0][0]).toMatchObject({
claudeRunningNonAgentTask: true,
claudeUnclassifiedBackgroundTask: false,
payload: { state: 'done', agentType: 'claude' }
})
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
payload: {
hook_event_name: 'Stop',
background_tasks: [{ id: 'task-1', type: 'agent', status: 'running' }]
}
})
})
expect(forward.mock.calls[1][0]).toMatchObject({
claudeRunningNonAgentTask: true,
claudeUnclassifiedBackgroundTask: true,
payload: { state: 'working', agentType: 'claude' }
})
} finally {
server.stop()
}
@@ -32,6 +32,7 @@ export type AgentStatusSetData = {
interrupted?: boolean
sessionBoundary?: boolean
turnCompletedAt?: number
providerBackgroundWorkActive?: boolean
terminalHandle?: string
launchToken?: string
providerSession?: { key: 'session_id'; id: string }
@@ -89,4 +89,76 @@ describe('useIpcEvents agent status turn completion', () => {
})
)
})
// Why: envelope-level fields are dropped by the payload rebuild in applyAgentStatus, and
// this one is what stops hibernation from killing a live dev server (STA-4119). The
// whitelist has silently eaten fields before, so pin it here rather than in the slice.
it('forwards providerBackgroundWorkActive through the agent-status IPC whitelist', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } },
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }]
}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
vi.doMock('./agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification: vi.fn(),
resetAgentHookCompletionNotificationCoordinators: vi.fn(),
syncAgentHookCompletionNotificationSettings: vi.fn(),
syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn()
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
const base = {
paneKey: FUTURE_PANE_KEY,
tabId: 'tab-future',
worktreeId: 'wt-1',
state: 'done',
prompt: 'start the dev server',
agentType: 'claude',
receivedAt: 1_700_000_005_000,
stateStartedAt: 1_700_000_000_000
} as const
onSetListenerRef.current({ ...base, providerBackgroundWorkActive: true })
expect(setAgentStatus).toHaveBeenLastCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'done', providerBackgroundWorkActive: true }),
expect.anything(),
expect.anything(),
expect.anything(),
undefined
)
expect(setAgentStatus).toHaveBeenCalledTimes(1)
})
})
+7 -1
View File
@@ -3357,6 +3357,12 @@ export function useIpcEvents(): void {
data.restoredUnconfirmed === true
? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true }
: statusPayloadWithTurnBoundary
// Why: same whitelist trap — envelope-level background-work evidence the payload
// rebuild drops, and pane hibernation reads it to avoid killing a live dev server.
const statusPayloadWithBackgroundWork =
data.providerBackgroundWorkActive === true
? { ...statusPayloadWithProvenance, providerBackgroundWorkActive: true }
: statusPayloadWithProvenance
const identity = resolveAgentStatusIdentity({
existing: existingStatus
? {
@@ -3396,7 +3402,7 @@ export function useIpcEvents(): void {
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
const update: AgentStatusUpdate = {
paneKey,
payload: statusPayloadWithProvenance,
payload: statusPayloadWithBackgroundWork,
terminalTitle,
timing: {
updatedAt: data.receivedAt,
@@ -102,6 +102,26 @@ describe('agent sleep planner', () => {
expect(plannedWorktrees(snapshot({ foregroundTerminalTabIds: ['tab-1'] }))).toEqual([])
})
// Why: STA-4119 let a pane reach `done` while a provider-owned shell keeps running, so
// `done` alone stopped implying "safe to kill this PTY". Hibernation tears the PTY down,
// which would take the user's dev server with it.
it('refuses a done pane whose provider still has live background work', () => {
const busy = entry({ providerBackgroundWorkActive: true })
expect(plannedWorktrees(snapshot({ agentStatusByPaneKey: { [busy.paneKey]: busy } }))).toEqual(
[]
)
const settled = entry({ providerBackgroundWorkActive: false })
expect(
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [settled.paneKey]: settled } }))
).toEqual(['wt-bg'])
const unknown = entry()
expect(
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [unknown.paneKey]: unknown } }))
).toEqual(['wt-bg'])
})
it('requires done resumable provider-session entries', () => {
for (const state of ['working', 'waiting', 'blocked'] as const) {
const e = entry({ state })
@@ -148,6 +148,11 @@ function getEligiblePane(args: {
entry.state !== 'done' ||
entry.interrupted === true ||
Boolean(entry.subagents?.length) ||
// Why: `done` means the agent is idle, not that the pane is disposable. A provider-owned
// background shell or session cron is still running under this PTY, and hibernation kills
// it — that is the user's dev server. Idle and safe-to-tear-down are different questions
// and STA-4119 is exactly what pulled them apart.
entry.providerBackgroundWorkActive === true ||
hasUnsettledOrUnknownDispatch(entry) ||
(sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity)
) {
@@ -198,3 +198,33 @@ describe('agent status stateStartedAt', () => {
expect(entry.updatedAt).toBe(2_000)
})
})
describe('provider background-work evidence', () => {
// Why: the pane's own hook stream restates this on every accepted status, so the entry must
// track it exactly — a value carried over from the previous row would keep a settled pane
// permanently ineligible for hibernation, and a dropped one would let hibernation kill a
// live dev server (STA-4119).
it('writes through on every accepted status instead of carrying the previous value', () => {
const store = createTestStore()
store.getState().setAgentStatus('tab-1:1', {
state: 'done',
prompt: 'start the dev server',
agentType: 'claude',
providerBackgroundWorkActive: true
})
expect(store.getState().agentStatusByPaneKey['tab-1:1']).toMatchObject({
state: 'done',
providerBackgroundWorkActive: true
})
store.getState().setAgentStatus('tab-1:1', {
state: 'done',
prompt: 'dev server stopped',
agentType: 'claude'
})
expect(
store.getState().agentStatusByPaneKey['tab-1:1']?.providerBackgroundWorkActive
).toBeUndefined()
})
})
@@ -112,6 +112,7 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & {
orchestration?: AgentStatusOrchestrationContext
promptInteractionKey?: string
restoredUnconfirmed?: boolean
providerBackgroundWorkActive?: boolean
}
export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number }
@@ -2194,6 +2195,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: write through rather than carrying the previous row's value — every accepted
// Claude status restates it, so a stale `true` would pin the pane awake forever.
providerBackgroundWorkActive: payload.providerBackgroundWorkActive,
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
interrupted: payload.interrupted,
// Why: done→done repaints (OSC 9999, reconnect snapshot replays) re-deliver a
+39 -6
View File
@@ -138,8 +138,13 @@ export type HookListenerState = {
claudeLeadStateByPaneKey: Map<string, ClaudeLeadTurnState>
/** One-normalization provenance marker for a status backed only by restored child state. */
claudeUnconfirmedRestoredStatusPaneKeys: Set<string>
/** Panes whose latest authoritative Claude task inventory still has running non-agent work. */
/** Panes whose latest authoritative Claude task inventory still has running non-agent work.
* Broad on purpose: it includes a recognised background shell, so consumers that must not
* destroy live provider work (pane hibernation) can see it. It does NOT gate pane state. */
claudeRunningNonAgentTaskPaneKeys: Set<string>
/** Panes whose latest authoritative Claude task inventory still has running work Orca could
* not positively classify as a non-agent shell. Fail-active: this DOES gate pane state. */
claudeUnclassifiedBackgroundTaskPaneKeys: Set<string>
/** Panes whose latest authoritative Claude cron inventory still has a scheduled job. */
claudeActiveSessionCronPaneKeys: Set<string>
/** Live thread-spawn children per Codex pane. */
@@ -181,6 +186,7 @@ export function createHookListenerState(): HookListenerState {
claudeLeadStateByPaneKey: new Map(),
claudeUnconfirmedRestoredStatusPaneKeys: new Set(),
claudeRunningNonAgentTaskPaneKeys: new Set(),
claudeUnclassifiedBackgroundTaskPaneKeys: new Set(),
claudeActiveSessionCronPaneKeys: new Set(),
codexSubagentRosterByPaneKey: new Map(),
codexSubagentTranscriptByPaneKey: new Map(),
@@ -198,6 +204,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.claudeLeadStateByPaneKey.delete(paneKey)
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey)
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
state.codexSubagentRosterByPaneKey.delete(paneKey)
state.codexSubagentTranscriptByPaneKey.delete(paneKey)
@@ -245,6 +252,7 @@ export function movePaneCacheState(
movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeUnconfirmedRestoredStatusPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeRunningNonAgentTaskPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeUnclassifiedBackgroundTaskPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedSetEntries(state.claudeActiveSessionCronPaneKeys, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey)
@@ -290,6 +298,7 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.claudeLeadStateByPaneKey.clear()
state.claudeUnconfirmedRestoredStatusPaneKeys.clear()
state.claudeRunningNonAgentTaskPaneKeys.clear()
state.claudeUnclassifiedBackgroundTaskPaneKeys.clear()
state.claudeActiveSessionCronPaneKeys.clear()
state.codexSubagentRosterByPaneKey.clear()
state.codexSubagentTranscriptByPaneKey.clear()
@@ -365,6 +374,8 @@ export type AgentHookEventPayload = {
isReplay?: boolean
/** Transport-only Claude background-work evidence used to reject false input-based interrupts. */
claudeRunningNonAgentTask?: boolean
/** See claudeUnclassifiedBackgroundTaskPaneKeys. Absent from pre-STA-4119 hosts. */
claudeUnclassifiedBackgroundTask?: boolean
payload: ParsedAgentStatusPayload
}
@@ -2584,6 +2595,7 @@ function updateClaudeRunningNonAgentTask(
state: HookListenerState,
paneKey: string,
hasRunningNonAgentTask: boolean,
hasRunningUnclassifiedTask: boolean,
interrupted: boolean
): void {
if (hasRunningNonAgentTask && !interrupted) {
@@ -2591,6 +2603,11 @@ function updateClaudeRunningNonAgentTask(
} else {
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
if (hasRunningUnclassifiedTask && !interrupted) {
state.claudeUnclassifiedBackgroundTaskPaneKeys.add(paneKey)
} else {
state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
}
}
function resolveClaudePaneState(
@@ -2602,11 +2619,14 @@ function resolveClaudePaneState(
return lead.state
}
const roster = state.claudeSubagentRosterByPaneKey.get(paneKey)
// Why: leftover provider shells/monitors are session inventory, not lead-turn
// work. Gating done→working on them latches the pane forever because an idle
// parent never emits another background_tasks list (STA-4119).
// Why: a recognised leftover shell is session inventory, not lead-turn work. Gating
// done→working on it latches the pane forever, because an idle parent never emits
// another background_tasks list (STA-4119). Work Orca could NOT classify still gates:
// it may be a child agent, and silently retiring live agent work is the worse failure.
return claudeRosterHasWorkingSubagent(roster) ||
(!lead.interrupted && state.claudeActiveSessionCronPaneKeys.has(paneKey))
(!lead.interrupted &&
(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey)))
? 'working'
: 'done'
}
@@ -2680,7 +2700,8 @@ function normalizeClaudeSubagentLifecycleEvent(
const hasConfirmedDoneGate =
cachedLead?.state === 'done' &&
cachedLead.interrupted !== true &&
state.claudeActiveSessionCronPaneKeys.has(paneKey)
(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey))
const restoredOnlyDoneGate =
cachedLead?.state === 'done' && !hasConfirmedDoneGate && hasUnconfirmedChild
if (roster?.size === 0) {
@@ -2705,6 +2726,7 @@ function normalizeClaudeSubagentLifecycleEvent(
export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void {
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true })
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
@@ -2903,6 +2925,7 @@ function normalizeClaudeEvent(
// fresh session's idle row back up to 'working' (same reset Codex does on SessionStart).
state.claudeSubagentRosterByPaneKey.delete(paneKey)
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' })
return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, {
@@ -2960,6 +2983,7 @@ function normalizeClaudeEvent(
state,
paneKey,
backgroundTasks.hasRunningNonAgentTask,
backgroundTasks.hasRunningUnclassifiedTask,
interrupted === true
)
}
@@ -3074,6 +3098,7 @@ function normalizeClaudeEvent(
if (interrupted && eventAgentId === undefined) {
state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
state.claudeUnclassifiedBackgroundTaskPaneKeys.delete(paneKey)
state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
@@ -3107,6 +3132,7 @@ function normalizeClaudeEvent(
effectiveState === 'working' &&
claudeRosterHasRestoredSnapshotSubagent(effectiveRoster) &&
!claudeRosterHasRuntimeWorkingSubagent(effectiveRoster) &&
!state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) &&
!state.claudeActiveSessionCronPaneKeys.has(paneKey)
) {
// Why: a legacy or partial Stop confirms the lead boundary, not a child restored from disk; keep the child-only gate eligible for reconciliation.
@@ -4585,8 +4611,15 @@ export function normalizeHookPayload(
toolAgentType: readString(hookPayloadRecord, 'agent_type'),
...(source === 'claude'
? {
// Why: unchanged meaning — ANY live provider-owned background work, shell
// included. Consumers that must not tear the pane down read this one.
claudeRunningNonAgentTask:
state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey),
// Why: the narrower fail-active signal that gates pane state. Optional on the
// wire: an older host omits it and the client falls back to the broad field.
claudeUnclassifiedBackgroundTask:
state.claudeUnclassifiedBackgroundTaskPaneKeys.has(paneKey) ||
state.claudeActiveSessionCronPaneKeys.has(paneKey)
}
: {}),
+6 -1
View File
@@ -103,8 +103,13 @@ export type AgentHookRelayEnvelope = {
providerSessionOnly?: boolean
/** True when the relay is replaying its cache after Orca reconnects. */
isReplay?: boolean
/** Claude background-work evidence for input-interrupt inference on the receiving host. */
/** Claude background-work evidence for input-interrupt inference on the receiving host.
* Broad: any live provider-owned background work, recognised shells included. */
claudeRunningNonAgentTask?: boolean
/** The fail-active subset that still gates pane state: running background work the host
* could not positively classify as a non-agent shell. Optional — hosts older than
* STA-4119 omit it and the receiver falls back to `claudeRunningNonAgentTask`. */
claudeUnclassifiedBackgroundTask?: boolean
/** Forwarded from the agent CLI POST body. The relay default is `remote`,
* which marks transport location rather than dev/prod build env. */
env?: string
+9 -5
View File
@@ -151,6 +151,12 @@ export type AgentStatusEntry = {
* the transition may have been missed while no receiver was up, so freshness gates
* treat the row as stale immediately. Cleared by any accepted live event. */
restoredUnconfirmed?: boolean
/** True while the provider still owns live background work on this pane — a running
* shell it launched, or a scheduled session cron — even though the turn itself is done.
* Why: `done` answers "is the agent idle", not "is this pane safe to tear down". Anything
* that kills the PTY (agent hibernation) must consult this or it silently kills the
* user's dev server. Live-only: a restarted listener cannot re-derive it. */
providerBackgroundWorkActive?: boolean
}
export type MigrationUnsupportedPtyEntry = {
@@ -252,16 +258,14 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
promptInteractionKey?: string
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
restoredUnconfirmed?: boolean
/** See AgentStatusEntry.providerBackgroundWorkActive. */
providerBackgroundWorkActive?: boolean
}
/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */
export type AgentStatusClearIpcPayload =
| { paneKey: string }
| {
transient: true
connectionId: string
clearedAt: number
}
| { transient: true; connectionId: string; clearedAt: number }
/** Maximum character length for the toolName field. */
export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60
+29 -2
View File
@@ -22,6 +22,12 @@ const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
'timed_out'
])
/** Types Claude uses for a plain provider-owned background shell. Only a type on
* this list is positively identified as "not agent work" and therefore allowed to
* stop gating the pane; anything else fails active. Keep it a whitelist — an
* unrecognised type must never be assumed harmless (STA-4119). */
const CLAUDE_NON_AGENT_SHELL_TASK_TYPES = new Set(['shell', 'background_shell'])
/** 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 = {
@@ -42,19 +48,35 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
present: boolean
tasks: ClaudeBackgroundAgentTask[]
truncated: boolean
/** Any running entry that is not an agent row: a positively identified shell OR
* something unclassifiable. Consumers that must not destroy live provider work
* (pane hibernation) use this broad signal. */
hasRunningNonAgentTask: boolean
/** The subset that could NOT be positively identified as a non-agent shell:
* unknown/future types, a blank or absent type, a malformed entry. This is the
* fail-active signal — it may still be agent work, so it keeps gating the pane
* at `working`. A recognised shell is deliberately absent from it. */
hasRunningUnclassifiedTask: boolean
} {
const raw = hookPayload['background_tasks']
if (!Array.isArray(raw)) {
return { present: false, tasks: [], truncated: false, hasRunningNonAgentTask: false }
return {
present: false,
tasks: [],
truncated: false,
hasRunningNonAgentTask: false,
hasRunningUnclassifiedTask: false
}
}
const tasks: ClaudeBackgroundAgentTask[] = []
let truncated = false
let hasRunningNonAgentTask = false
let hasRunningUnclassifiedTask = false
for (const item of raw) {
if (typeof item !== 'object' || item === null) {
truncated = true
hasRunningNonAgentTask = true
hasRunningUnclassifiedTask = true
continue
}
const obj = item as Record<string, unknown>
@@ -65,12 +87,17 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
if (taskType.length === 0) {
truncated = true
hasRunningNonAgentTask ||= !isTerminal
hasRunningUnclassifiedTask ||= !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
// Why: a recognised shell is the ONE case we can prove is not agent work, so
// only it may stop gating the pane. Everything else — `type: "agent"`, a type
// Orca has never seen — stays unclassified and keeps the pane `working`.
hasRunningUnclassifiedTask ||= !CLAUDE_NON_AGENT_SHELL_TASK_TYPES.has(taskType)
}
if (!isAgentTask) {
continue
@@ -93,5 +120,5 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
teammate: taskType === 'teammate'
})
}
return { present: true, tasks, truncated, hasRunningNonAgentTask }
return { present: true, tasks, truncated, hasRunningNonAgentTask, hasRunningUnclassifiedTask }
}
@@ -41,6 +41,17 @@ describe('Claude background task status', () => {
expect(result).toMatchObject({ truncated: true, hasRunningNonAgentTask: true })
expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS)
// Why: with a full roster present, `working` is carried by the child rows, so this
// asserts the monitor ALONE — otherwise the monitor half of the gate is untested.
const monitorOnly = createHookListenerState()
claudeEvent(monitorOnly, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'watch' })
expect(
claudeEvent(monitorOnly, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [{ id: 'monitor-1', type: 'monitor', status: 'pending' }]
})
).toMatchObject({ state: 'working', subagents: undefined })
const state = createHookListenerState()
expect(
claudeEvent(state, SOURCE_PANE, {
@@ -150,6 +161,79 @@ describe('Claude background task status', () => {
).toBe('working')
})
// Why: the whole point of STA-4119's narrowing. Only a POSITIVELY identified non-agent
// shell may stop gating; anything Orca cannot classify may still be agent work, and
// retiring live agent work is the worse failure. Guards the fail-active property in
// readClaudeBackgroundAgentTasks, which had no end-to-end coverage before.
it.each([
{ label: 'a future agent type', task: { id: 't', type: 'agent', status: 'running' } },
{ label: 'an unrecognised type', task: { id: 't', type: 'workflow', status: 'running' } },
{ label: 'a monitor', task: { id: 't', type: 'monitor', status: 'running' } },
{ label: 'a blank type', task: { id: 't', type: ' ', status: 'running' } },
{ label: 'an absent type', task: { id: 't', status: 'running' } }
])('holds a finished lead at working for $label', ({ task }) => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
expect(
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: [task] })
).toMatchObject({ state: 'working', subagents: undefined })
expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
// Why: it must still clear once the same inventory reports the entry finished —
// failing active may not become a second one-way latch.
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [{ ...task, status: 'completed' }]
})?.state
).toBe('done')
expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(false)
})
it('holds a finished lead at working for a malformed inventory entry', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
expect(
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'Stop', background_tasks: ['garbled'] })
?.state
).toBe('working')
expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
})
it.each([
{ label: 'shell', type: 'shell' },
{ label: 'background_shell', type: 'background_shell' },
{ label: 'SHELL cased and padded', type: ' Shell ' }
])('does not hold a finished lead at working for a recognised $label', ({ type }) => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [{ id: 'sh-1', type, status: 'running' }]
})?.state
).toBe('done')
// Why: it stops gating pane state, but stays visible as live provider work so pane
// teardown (hibernation) can still refuse to kill it.
expect(state.claudeUnclassifiedBackgroundTaskPaneKeys.has(SOURCE_PANE)).toBe(false)
expect(state.claudeRunningNonAgentTaskPaneKeys.has(SOURCE_PANE)).toBe(true)
})
it('keeps a recognised shell from masking an unclassified sibling', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, { hook_event_name: 'UserPromptSubmit', prompt: 'go' })
expect(
claudeEvent(state, SOURCE_PANE, {
hook_event_name: 'Stop',
background_tasks: [RUNNING_SHELL, { id: 'x-1', type: 'agent', status: 'running' }]
})?.state
).toBe('working')
})
it('keeps pending task state when pane authority moves', () => {
const state = createHookListenerState()
claudeEvent(state, SOURCE_PANE, {