diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 0d159fad3a50..070d6853464b 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -366,8 +366,8 @@ describe('host-owned hook presence', () => { expect(probe).not.toHaveBeenCalled() expect(visible(server)).toBe(true) await hook(server, 'SessionStart', 'relaunch', undefined, 4002) - await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) - await vi.waitFor(() => expect(visible(server)).toBe(false)) + expect(probe).toHaveBeenCalledOnce() + expect(state(server)).toBe('done') await hook(server, 'UserPromptSubmit', 'relaunch', undefined, 4002) expect(state(server)).toBe('working') }) @@ -505,10 +505,12 @@ describe('retained agent exit', () => { finish = resolve }) ) - await hook(server, 'SessionStart', 'replacement', undefined, 4002) + const replacement = hook(server, 'SessionStart', 'replacement', undefined, 4002) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) const overlapping = server.checkAgentPresenceSince(PANE, attachedAt) finish('exited') expect(await overlapping).not.toBe('exited') + await replacement }) it('keeps the exit through a delayed hook from the ended session without a process identity', async () => { diff --git a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts index ce86479d2b5f..ec6123562249 100644 --- a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts +++ b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts @@ -9,6 +9,10 @@ vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) +// Why: the fixture owner pid is not a real process; the pane's owner is alive in every case here. +vi.mock('../../shared/agent-process-presence-probe', () => ({ + probeAgentProcessPresence: vi.fn(async () => 'live') +})) beforeEach(() => { _internals.resetCachesForTests() diff --git a/src/main/agent-hooks/server-claude-tmux-teammate.test.ts b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts new file mode 100644 index 000000000000..404de7832c62 --- /dev/null +++ b/src/main/agent-hooks/server-claude-tmux-teammate.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import type { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +const probe = vi.hoisted(() => vi.fn(async () => 'live')) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + probe.mockReset().mockResolvedValue('live') +}) + +const LEAD = { pid: 4001, session: 'lead-session' } +const TEAMMATE = { pid: 4002, session: 'teammate-session' } + +async function post( + server: AgentHookServer, + sender: typeof LEAD, + payload: Record +): Promise { + const response = await postHookEvent( + server, + buildBody( + { session_id: sender.session, ...payload }, + { + agentProcess: JSON.stringify({ + pid: sender.pid, + platform: 'linux', + startTime: `birth-${sender.pid}` + }) + } + ) + ) + expect(response.status).toBe(204) +} + +function paneState(server: AgentHookServer): string | undefined { + return server.getStatusSnapshot().find((row) => row.paneKey === PANE)?.state +} + +async function leadAsksQuestion(server: AgentHookServer): Promise { + await post(server, LEAD, { hook_event_name: 'SessionStart', source: 'startup' }) + await post(server, LEAD, { hook_event_name: 'UserPromptSubmit', prompt: 'Spawn a teammate' }) + const ask = { tool_name: 'AskUserQuestion', tool_use_id: 'toolu_ask' } + await post(server, LEAD, { hook_event_name: 'PreToolUse', ...ask }) + await post(server, LEAD, { hook_event_name: 'PermissionRequest', tool_name: 'AskUserQuestion' }) +} + +// #30833: a tmux teammate runs in Claude's own tmux server, inheriting the lead pane's ORCA_PANE_KEY. +describe('Claude tmux teammate hooks on the lead pane', () => { + it('keeps the lead waiting on its question while the teammate process runs tools', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + await leadAsksQuestion(server) + expect(paneState(server)).toBe('waiting') + + const teammate = { agent_type: 'general-purpose' } + await post(server, TEAMMATE, { + hook_event_name: 'SessionStart', + source: 'startup', + ...teammate + }) + for (const id of ['toolu_1', 'toolu_2']) { + const bash = { ...teammate, tool_name: 'Bash', tool_use_id: id } + await post(server, TEAMMATE, { hook_event_name: 'PreToolUse', ...bash }) + expect(paneState(server)).toBe('waiting') + await post(server, TEAMMATE, { hook_event_name: 'PostToolUse', ...bash }) + expect(paneState(server)).toBe('waiting') + } + await post(server, TEAMMATE, { hook_event_name: 'Stop', ...teammate }) + await post(server, TEAMMATE, { hook_event_name: 'TeammateIdle', teammate_name: 'worker' }) + expect(paneState(server)).toBe('waiting') + + await post(server, LEAD, { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu_ask' + }) + expect(paneState(server)).toBe('working') + }) + + it('keeps the dead lead exit when only its teammate ends afterwards', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const attachedAt = Date.now() - 1 + await leadAsksQuestion(server) + probe.mockResolvedValue('exited') + + await post(server, TEAMMATE, { hook_event_name: 'SessionEnd', reason: 'other' }) + expect(await server.checkAgentPresenceSince(PANE, attachedAt)).toBe('exited') + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index a067e24e4b11..695097cf3b10 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -153,6 +153,8 @@ export function normalizeHookPayload( ) { return null } + // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. + const sender = source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined const previousStatus = state.lastStatusByPaneKey.get(paneKey) const previousReceivedAt = previousStatus && @@ -177,6 +179,7 @@ export function normalizeHookPayload( agentPresence: previousStatus.providerSessionOnly ? undefined : previousStatus.agentPresence }, incoming: source, + incomingProcess: sender, now: Date.now() }).inheritedFromActivePane ) { @@ -222,11 +225,8 @@ export function normalizeHookPayload( } } - // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. - const agentProcess = - source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined - const agentPresence = agentProcess - ? { agent: source, process: agentProcess, sessionOwner: true as const } + const agentPresence = sender + ? { agent: source, process: sender, sessionOwner: true as const } : undefined const sessionEndReason = readString(hookPayloadRecord, 'reason') if ( diff --git a/src/shared/agent-hook-owner-presence.ts b/src/shared/agent-hook-owner-presence.ts index 64c7aaebf9b4..816a42cafb8c 100644 --- a/src/shared/agent-hook-owner-presence.ts +++ b/src/shared/agent-hook-owner-presence.ts @@ -1,7 +1,7 @@ import { parseHookEnvelope } from './agent-hook-listener/hook-envelope' import type { HookListenerState } from './agent-hook-listener/listener-state' import type { AgentHookSource } from './agent-hook-relay' -import { hasOtherAgentProcessOwner } from './agent-process-presence' +import { hasOtherAgentProcessOwner, readAgentProcessIdentity } from './agent-process-presence' import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner' /** Confirm an old owner's exit on its execution host before accepting a replacement. */ @@ -26,7 +26,11 @@ export function checkHookOwnerPresence( { connectionId: previous.connectionId ?? null, worktreeId: previous.worktreeId }, { connectionId: null, worktreeId: envelope.worktreeId } ) && - hasOtherAgentProcessOwner(presence && { ...presence, suspended: undefined }, source) + hasOtherAgentProcessOwner( + presence && { ...presence, suspended: undefined }, + source, + readAgentProcessIdentity(envelope.record.agentProcess) + ) ) { return check(envelope.paneKey) } diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index d9e522a17a0f..51ebcea84b29 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -12,7 +12,7 @@ export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity ended?: true - /** This host verifies process ownership before accepting a different provider. */ + /** This host verifies process ownership before accepting another agent process. */ sessionOwner?: true /** Positive host observation of suspension, distinct from an unavailable process read. */ suspended?: true @@ -77,16 +77,20 @@ export function isSameAgentProcess(a: AgentProcessIdentity, b: AgentProcessIdent return a.pid === b.pid && a.platform === b.platform && a.startTime === b.startTime } +/** Another live agent owns the pane: a different provider, or the same one in another proven process. */ export function hasOtherAgentProcessOwner( presence: AgentProcessPresence | undefined, - incoming: string + incoming: string, + incomingProcess?: AgentProcessIdentity ): boolean { return Boolean( presence?.process && presence.sessionOwner && !presence.ended && !presence.suspended && - presence.agent !== incoming && - presence.agent !== 'unknown' + presence.agent !== 'unknown' && + // Why: a Claude tmux teammate is a separate claude process that inherits the lead's ORCA_PANE_KEY. + (presence.agent !== incoming || + (incomingProcess && !isSameAgentProcess(presence.process, incomingProcess))) ) } diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts index b9ca92a89592..5e86c0d882dd 100644 --- a/src/shared/agent-status-identity.ts +++ b/src/shared/agent-status-identity.ts @@ -4,7 +4,11 @@ import { type AgentStatusState, type AgentType } from './agent-status-types' -import { hasOtherAgentProcessOwner, type AgentProcessPresence } from './agent-process-presence' +import { + hasOtherAgentProcessOwner, + type AgentProcessIdentity, + type AgentProcessPresence +} from './agent-process-presence' import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter' import type { TuiAgent } from './tui-agent' @@ -46,6 +50,7 @@ function isActiveExistingIdentity( export function resolveAgentStatusIdentity(args: { existing?: ExistingAgentIdentity incoming?: AgentType + incomingProcess?: AgentProcessIdentity now: number staleAfterMs?: number }): AgentIdentityResolution { @@ -59,12 +64,24 @@ export function resolveAgentStatusIdentity(args: { inheritedFromActivePane: false } } - if (!args.existing || !existingAgentType || existingAgentType === incomingAgentType) { + if (!args.existing || !existingAgentType) { return { agentType: incomingAgentType, inheritedFromActivePane: false } } + if (existingAgentType === incomingAgentType) { + return { + agentType: incomingAgentType, + inheritedFromActivePane: + args.incomingProcess !== undefined && + hasOtherAgentProcessOwner( + args.existing.agentPresence, + incomingAgentType, + args.incomingProcess + ) + } + } const canonical = resolveCanonicalPaneAgentIdentity({ hookAgent: incomingAgentType as TuiAgent, hookIsLive: true,