mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user