fix(agent-status): let a pane's owner reclaim it from a nested agent's hooks (#33572)

A pane's owning agent takes the pane back from a nested/background child on its next hook when the hook matches the owner's recorded session (agent type and session id), instead of staying hijacked until the child finishes; stale owners and nested same-type children with new sessions stay rejected. No recorded session (older hosts/relays) keeps main's behavior.

Fixes #30589
Refs #13341
This commit is contained in:
Neil
2026-10-25 09:44:44 -07:00
committed by GitHub
parent 9485e73207
commit a944e37a62
14 changed files with 207 additions and 34 deletions
@@ -203,4 +203,87 @@ describe('nested agent hooks preserve the owning pane', () => {
expect(pushed).not.toHaveBeenCalled()
server.stop()
})
// #30589: a background child that posts while the owner idles must not then silence the owner.
it("returns the pane to its owner when the owner's next hook follows a nested agent", async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
const nestedClaude = {
agentProcess: JSON.stringify({ pid: 4242, platform: 'darwin', startTime: 'nested' })
}
const post = (source: AgentHookSource, payload: Record<string, unknown>, overrides = {}) =>
postHookEvent(server, buildBody(payload, overrides), `/hook/${source}`)
try {
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'owner',
prompt: 'Start job'
})
await post('codex', { hook_event_name: 'Stop', session_id: 'owner' })
await post(
'claude',
{ hook_event_name: 'UserPromptSubmit', session_id: 'child', prompt: 'Background job' },
nestedClaude
)
expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType: 'claude', state: 'working' })
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'owner',
prompt: 'Next task'
})
await post(
'claude',
{ hook_event_name: 'PreToolUse', session_id: 'child', tool_name: 'Bash' },
nestedClaude
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
agentType: 'codex',
state: 'working',
prompt: 'Next task',
providerSession: { id: 'owner' }
})
} finally {
server.stop()
}
})
it('keeps a stale owner type from letting a nested codex exec take the pane over the relay', async () => {
const server = new AgentHookServer()
const dir = mkdtempSync(join(tmpdir(), 'orca-stale-owner-'))
const relay = new RelayAgentHookServer({
endpointDir: dir,
forward: (event) => server.ingestRemote(event, 'conn-1')
})
await relay.start()
const post = (source: AgentHookSource, payload: Record<string, unknown>) =>
fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': relay.getCoordinates().token
},
body: JSON.stringify(buildBody(payload))
})
try {
await post('codex', { hook_event_name: 'UserPromptSubmit', session_id: 'old', prompt: 'Old' })
await post('codex', { hook_event_name: 'Stop', session_id: 'old' })
await post('claude', { hook_event_name: 'UserPromptSubmit', session_id: 'c', prompt: 'Now' })
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'exec',
prompt: 'Review'
})
expect(server.getStatusSnapshot()[0]).toMatchObject({
agentType: 'claude',
prompt: 'Now',
providerSession: { id: 'c' }
})
} finally {
relay.stop()
rmSync(dir, { recursive: true, force: true })
}
})
})
@@ -122,6 +122,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
incoming: terminalOwnedPayload.payload.agentType,
// Relay presence is the execution host's owner verdict, not the sender's identity.
incomingProcess: payload.connectionId ? undefined : incoming.agentPresence?.process,
incomingSession: terminalOwnedPayload.providerSession?.id,
now
})
if (
+5 -1
View File
@@ -35,7 +35,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
authorityRestartId,
structuredHost,
programStatus,
programStatusOnly
programStatusOnly,
agentPresence
}) => {
if (state.mainWindow?.isDestroyed()) {
return
@@ -86,6 +87,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
...(orchestration ? { orchestration } : {}),
...(programStatus ? { programStatus } : {}),
...(programStatusOnly === true ? { programStatusOnly: true } : {}),
...(agentPresence?.session && !agentPresence.ended
? { paneOwner: { agent: agentPresence.agent, session: agentPresence.session } }
: {}),
// Mark reconnect replays so cached completions do not notify again.
...(isReplay === true ? { isReplay: true } : {})
}
@@ -1,10 +1,5 @@
import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner'
import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract'
import { pickHostTurnEnd, type AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
} from '../../../../shared/agent-status-identity'
import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { normalizeProgramStatusFacet } from '../../../../shared/program-status/facet'
@@ -18,6 +13,7 @@ import {
applyResolvedAgentTerminalTitleToTab,
hasRuntimeBackedWorktreeAttribution,
isAgentStatusForRecentlyClosedTab,
isInheritedFromActivePane,
isSeenHostReplay,
resolveHookPayloadAgentType,
shouldApplyResolvedAgentTerminalTitleToTab
@@ -199,31 +195,11 @@ export function createAgentStatusEventApplicator(args: {
: statusPayloadWithProvenance
// Why null: main stitches the pane's current facet onto every publish, so absence means cleared.
const statusPayloadWithProgramStatus = { ...statusPayloadWithObservation, programStatus }
const identity = resolveAgentStatusIdentity({
existing:
existingStatus &&
sameAgentStatusTerminalOwner(
{
connectionId: existingStatus.connectionId ?? null,
worktreeId: existingStatus.worktreeId
},
{
connectionId:
(ownershipConnectionId === undefined
? existingStatus.connectionId
: ownershipConnectionId) ?? null,
worktreeId: data.worktreeId ?? owningWorktreeId ?? existingStatus.worktreeId
}
)
? existingStatus
: undefined,
incoming: statusPayload.agentType,
now: data.receivedAt
})
if (
existingStatus &&
shouldSuppressInheritedTerminalStatus({
inheritedFromActivePane: identity.inheritedFromActivePane
isInheritedFromActivePane(existingStatus, data, statusPayload.agentType, {
connectionId: ownershipConnectionId,
worktreeId: owningWorktreeId
})
) {
return 'dropped'
@@ -2,9 +2,13 @@ import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authorit
import type { AppState } from '../../store/types'
import { titleHasAgentName } from '../../../../shared/agent-detection'
import type {
AgentStatusEntry,
AgentStatusIpcPayload,
AgentType,
ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import { resolveAgentStatusIdentity } from '../../../../shared/agent-status-identity'
import { sameAgentStatusTerminalOwner } from '../../../../shared/agent-status-terminal-owner'
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import type { useAppStore } from '../../store'
@@ -130,4 +134,30 @@ export function resolveHookPayloadAgentType(
return { ...payload, agentType: 'openclaude' }
}
/** Nested CLIs inherit the pane key; drop their hooks unless the host names them the pane's owner. */
export function isInheritedFromActivePane(
existing: AgentStatusEntry,
data: AgentStatusIpcPayload,
incoming: AgentType | undefined,
owner: { connectionId: string | null | undefined; worktreeId: string | undefined }
): boolean {
const sameTerminal = sameAgentStatusTerminalOwner(
{ connectionId: existing.connectionId ?? null, worktreeId: existing.worktreeId },
{
connectionId:
(owner.connectionId === undefined ? existing.connectionId : owner.connectionId) ?? null,
worktreeId: data.worktreeId ?? owner.worktreeId ?? existing.worktreeId
}
)
return (
sameTerminal &&
resolveAgentStatusIdentity({
existing: { ...existing, agentPresence: data.paneOwner },
incoming,
incomingSession: data.providerSession?.id,
now: data.receivedAt
}).inheritedFromActivePane
)
}
export { resolvePaneKey } from '../../lib/agent-status-pane-ownership'
@@ -31,12 +31,13 @@ export function normalizeAgentStatusMetadata(
data: AgentStatusIpcPayload,
authorityRestartId?: string
): AgentStatusMetadata | undefined {
if (!data.providerSession && !data.launchToken && !authorityRestartId) {
if (!data.providerSession && !data.launchToken && !authorityRestartId && !data.paneOwner) {
return undefined
}
return {
...(authorityRestartId ? { authorityRestartId } : {}),
...(data.providerSession ? { providerSession: data.providerSession } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {})
...(data.launchToken ? { launchToken: data.launchToken } : {}),
...(data.paneOwner ? { paneOwner: data.paneOwner } : {})
}
}
@@ -112,6 +112,7 @@ export type AgentStatusMetadata = {
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
terminalResumeEligible?: false
paneOwner?: { agent: AgentType; session: string }
}
export type AgentStatusUpdate = {
@@ -97,9 +97,10 @@ export function buildAgentStatusLiveEntry(
worktreeId: routing?.worktreeId ?? existing.worktreeId
}
)
? existing
? { ...existing, agentPresence: metadata?.paneOwner }
: undefined,
incoming: payload.agentType,
incomingSession: metadata?.providerSession?.id,
now: updatedAt
})
const commandCodeNewTurn =
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
const NOW = Date.UTC(2026, 9, 25, 12)
afterEach(() => vi.useRealTimers())
// #30589: a nested agent's row arrived while the owner idled; the owner's next hook must land.
function reclaim(session?: string) {
const store = createTestStore()
vi.useFakeTimers()
vi.setSystemTime(NOW)
store
.getState()
.setAgentStatus(PANE_KEY, { state: 'working', prompt: 'Background job', agentType: 'claude' })
store.getState().setAgentStatus(
PANE_KEY,
{ state: 'working', prompt: 'Next task', agentType: 'codex' },
undefined,
{ updatedAt: NOW + 1 },
undefined,
session
? {
paneOwner: { agent: 'codex', session: 'owner' },
providerSession: { key: 'session_id', id: session }
}
: undefined
)
return store.getState().agentStatusByPaneKey[PANE_KEY]
}
describe('pane owner reclaim', () => {
it("accepts the owner's hook over a fresh nested-agent row", () => {
expect(reclaim('owner')).toMatchObject({
agentType: 'codex',
prompt: 'Next task'
})
})
it.each([undefined, 'nested-exec'])('suppresses a different agent from session %s', (session) => {
expect(reclaim(session)).toMatchObject({ agentType: 'claude', prompt: 'Background job' })
})
})
+1
View File
@@ -167,6 +167,7 @@ export function normalizeHookPayload(
},
incoming: source,
incomingProcess: agentProcess,
incomingSession: providerSession?.id,
now: Date.now()
}).inheritedFromActivePane
) {
+11 -2
View File
@@ -27,7 +27,10 @@ export function transitionHookPresence(
: undefined
}
if (sender && owner.process && isSameAgentProcess(sender, owner.process)) {
return incoming
const session = owner.session
return session
? { ...incoming, agentPresence: { ...owner, ...incoming.agentPresence, session } }
: incoming
}
// Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership.
return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner }
@@ -47,8 +50,14 @@ export function transitionHookPresence(
if (!agent || agent === 'unknown') {
return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming
}
const session = incoming.agentPresence?.session ?? incoming.providerSession?.id
return {
...incoming,
agentPresence: { ...incoming.agentPresence, agent, ...(sender ? { process: sender } : {}) }
agentPresence: {
...incoming.agentPresence,
agent,
...(sender ? { process: sender } : {}),
...(session ? { session } : {})
}
}
}
+7
View File
@@ -11,6 +11,8 @@ export type AgentProcessIdentity = {
export type AgentProcessPresence = {
agent: AgentType
process?: AgentProcessIdentity
/** Provider session that claimed the pane; later turns of the owner reuse it. */
session?: string
ended?: true
/** This host verifies process ownership before accepting a different provider. */
sessionOwner?: true
@@ -70,9 +72,14 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence |
return undefined
}
const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined
const session =
'session' in value && typeof value.session === 'string' && value.session.length <= 256
? value.session
: undefined
return {
agent: value.agent,
...(process ? { process } : {}),
...(session ? { session } : {}),
...('ended' in value && value.ended === true ? { ended: true as const } : {}),
...('sessionOwner' in value && value.sessionOwner === true
? { sessionOwner: true as const }
+12
View File
@@ -51,6 +51,7 @@ export function resolveAgentStatusIdentity(args: {
existing?: ExistingAgentIdentity
incoming?: AgentType
incomingProcess?: AgentProcessIdentity
incomingSession?: string
now: number
staleAfterMs?: number
}): AgentIdentityResolution {
@@ -81,6 +82,17 @@ export function resolveAgentStatusIdentity(args: {
inheritedFromActivePane: otherProcessOwner
}
}
const owner = args.existing.agentPresence
if (
owner?.session &&
!owner.ended &&
owner.agent === incomingAgentType &&
owner.session === args.incomingSession &&
!otherProcessOwner
) {
// Why: a child that posted while the owner idled must not silence the owner's own session.
return { agentType: incomingAgentType, inheritedFromActivePane: false }
}
const canonical = resolveCanonicalPaneAgentIdentity({
hookAgent: incomingAgentType as TuiAgent,
hookIsLive: true,
+3
View File
@@ -11,6 +11,7 @@ import type {
} from './agent-status-run'
import type {
AgentStatusOrchestrationContext,
AgentType,
ParsedAgentStatusPayload,
TurnEndDisposition
} from './agent-status-types'
@@ -68,6 +69,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
programStatusOnly?: true
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
restoredUnconfirmed?: boolean
/** The pane's owning agent session on its execution host; absent from older hosts. */
paneOwner?: { agent: AgentType; session: string }
/** A re-send of a cached row (reconnect or listener replay), not a new transition. */
isReplay?: boolean
/** Present on rows the structured session host projects; `owned` keeps them fresh past the