From 52f4db7196367ecb7d0e3a01c8f53cc260987e18 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:20:32 -0400 Subject: [PATCH] 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 --- .../program-status/hook-precedence.test.ts | 11 ++++++++++ .../server/program-status/hook-precedence.ts | 7 ++++-- .../agent-hook-listener-antigravity.test.ts | 16 ++++++++++---- .../providers/antigravity-events.ts | 22 +++++++++++-------- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts index c873db8f785..146f4a97397 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts @@ -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' }) diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts index df80f7f8bbe..b32f0bfc296 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -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 } diff --git a/src/shared/agent-hook-listener-antigravity.test.ts b/src/shared/agent-hook-listener-antigravity.test.ts index 8bf775ba567..fa1ec17efbb 100644 --- a/src/shared/agent-hook-listener-antigravity.test.ts +++ b/src/shared/agent-hook-listener-antigravity.test.ts @@ -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 }) diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts index f30e6c555d5..3e8b36ab6d6 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-events.ts +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -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) {