mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -167,6 +167,7 @@ export function normalizeHookPayload(
|
||||
},
|
||||
incoming: source,
|
||||
incomingProcess: agentProcess,
|
||||
incomingSession: providerSession?.id,
|
||||
now: Date.now()
|
||||
}).inheritedFromActivePane
|
||||
) {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user