fix(agent-status): keep an Antigravity run working while its helper agents finish (#34128)

When an Antigravity parent ends its turn to wait for invoke_subagent helpers, agy reports the parent's Stop as fullyIdle=false, but its own terminal status says done at each turn end. After two minutes of hook silence Orca let that terminal done replace the hook row, so automations completed while helpers were still working. The parent's busy Stop now marks the row as held open for helpers, and a program's non-boundary done can no longer settle a row held open for child work. Late helper hooks after a completed turn are rejected before they touch parent state.

Verified with real agy 1.4.7: helpers slower than two minutes ended runs early on main (3/3) and complete with all helper output and the final report on this change.

Fixes #33973
This commit is contained in:
Jinwoo Hong
2026-10-26 20:20:32 -04:00
committed by GitHub
parent 875d89af50
commit 52f4db7196
4 changed files with 41 additions and 15 deletions
@@ -80,6 +80,17 @@ describe('programStatusHookYieldEndsAt', () => {
expect(programStatusHookYieldEndsAt(hook, heldOpen, NOW + 1)).toBe(Infinity)
})
it("never lets a program's done settle a turn the hooks hold open for children", () => {
const heldOpen = row({
state: 'working',
agentType: 'antigravity',
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: NOW }
})
expect(
programStatusHookYieldEndsAt(heldOpen, report({ state: 'done', agentType: 'antigravity' }))
).toBe(Infinity)
})
it('keeps a question the hooks raised only from a working report that began before it', () => {
for (const state of ['waiting', 'blocked'] as const) {
const hook = row({ state, agentType: 'pi' })
@@ -20,10 +20,13 @@ export function programStatusHookYieldEndsAt(
if (!sameAgent && previous.payload.state === 'done') {
return undefined
}
// Why: the hook lane tracks its own children; a program's child roster must not reopen the hook's settled turn.
// Hook and program child rosters cannot settle or reopen each other's turns.
if (
payload.state === previous.payload.state ||
(previous.payload.state === 'done' && isAgentStatusHeldOpenByChildWork(payload))
(previous.payload.state === 'done' && isAgentStatusHeldOpenByChildWork(payload)) ||
(payload.state === 'done' &&
payload.sessionBoundary !== true &&
isAgentStatusHeldOpenByChildWork(previous.payload))
) {
return Infinity
}
@@ -413,6 +413,7 @@ describe('shared agent-hook-listener', () => {
state: 'working',
agentType: 'antigravity'
})
expect(event?.payload.mainAgent).toBeUndefined()
})
it('keeps Antigravity tool hooks active after a non-idle Stop for the same transcript', () => {
@@ -513,10 +514,10 @@ describe('shared agent-hook-listener', () => {
'antigravity',
{ paneKey: PANE_KEY, hook_event_name: hookEventName, payload },
'production'
)?.payload.state ?? null
)?.payload ?? null
try {
const states = [
const events = [
send('PreInvocation', parent),
send('PreToolUse', { ...parent, toolCall: subagentCall }),
send('PostToolUse', { ...parent, toolCall: subagentCall }),
@@ -526,7 +527,7 @@ describe('shared agent-hook-listener', () => {
send('Stop', { ...child, fullyIdle: true }),
send('PreInvocation', parent)
]
expect(states).toEqual([
expect(events.map((event) => event?.state ?? null)).toEqual([
'working',
'working',
'working',
@@ -536,6 +537,8 @@ describe('shared agent-hook-listener', () => {
null,
'working'
])
// Held open for helpers, so the terminal's done for the parent's turn cannot settle it.
expect(events[4]?.mainAgent?.state).toBe('done')
const done = normalizeHookPayload(
state,
@@ -547,8 +550,13 @@ describe('shared agent-hook-listener', () => {
state: 'done',
lastAssistantMessage: 'Parent final report'
})
expect(send('PostToolUse', { ...parent, toolCall: subagentCall })).toBeNull()
expect(send('PreInvocation', parent)?.state).toBe('working')
const nextStop = send('Stop', { ...parent, fullyIdle: false })
expect(nextStop?.state).toBe('working')
expect(nextStop?.mainAgent).toBeUndefined()
expect(
send('PreInvocation', { conversationId: 'next-conversation', lastUserInput: 'next' })
send('PreInvocation', { conversationId: 'next-conversation', lastUserInput: 'next' })?.state
).toBe('working')
} finally {
rmSync(tmpDir, { recursive: true, force: true })
@@ -35,14 +35,6 @@ export function normalizeAntigravityEvent(
}
state.antigravitySubagentParentByPaneKey.delete(paneKey)
}
const toolName = readAntigravityToolCall(hookPayload).toolName
if (
conversationId &&
(eventName === 'PreToolUse' || eventName === 'PostToolUse') &&
isAntigravitySubagentTool(toolName)
) {
state.antigravitySubagentParentByPaneKey.set(paneKey, conversationId)
}
if (eventName === 'PreInvocation') {
state.antigravityCompletedTranscriptByPaneKey.delete(paneKey)
} else if (
@@ -54,7 +46,18 @@ export function normalizeAntigravityEvent(
return null
}
const toolName = readAntigravityToolCall(hookPayload).toolName
if (
conversationId &&
(eventName === 'PreToolUse' || eventName === 'PostToolUse') &&
isAntigravitySubagentTool(toolName)
) {
state.antigravitySubagentParentByPaneKey.set(paneKey, conversationId)
}
const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload)
// agy ends the parent's turn with fullyIdle=false while its helpers run; they hold the pane open.
const heldOpenByHelpers = stopStillBusy && state.antigravitySubagentParentByPaneKey.has(paneKey)
const stateName =
eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName)
? 'waiting'
@@ -95,7 +98,8 @@ export function normalizeAntigravityEvent(
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
mainAgent: heldOpenByHelpers ? { state: 'done', stateStartedAt: Date.now() } : undefined
})
// Stop between tools can report fullyIdle=false; only an idle Stop is terminal.
if (eventName === 'Stop' && !stopStillBusy) {