Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/main/agent-hooks/server-agent-presence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
100 changes: 100 additions & 0 deletions src/main/agent-hooks/server-claude-tmux-teammate.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof probeAgentProcessPresence>(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<string, unknown>
): Promise<void> {
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<void> {
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')
})
})
10 changes: 5 additions & 5 deletions src/shared/agent-hook-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -177,6 +179,7 @@ export function normalizeHookPayload(
agentPresence: previousStatus.providerSessionOnly ? undefined : previousStatus.agentPresence
},
incoming: source,
incomingProcess: sender,
now: Date.now()
}).inheritedFromActivePane
) {
Expand Down Expand Up @@ -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 (
Expand Down
8 changes: 6 additions & 2 deletions src/shared/agent-hook-owner-presence.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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)
}
Expand Down
12 changes: 8 additions & 4 deletions src/shared/agent-process-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
)
}
21 changes: 19 additions & 2 deletions src/shared/agent-status-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -46,6 +50,7 @@ function isActiveExistingIdentity(
export function resolveAgentStatusIdentity(args: {
existing?: ExistingAgentIdentity
incoming?: AgentType
incomingProcess?: AgentProcessIdentity
now: number
staleAfterMs?: number
}): AgentIdentityResolution {
Expand All @@ -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,
Expand Down