diff --git a/AGENTS.md b/AGENTS.md index e41616285030..9c5a63c86cc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu ## Agent Status -The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. +The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. Each pane's row has one owner (`agentPresence`), decided by `transitionHookPresence` on the execution host (main for local panes, the relay for SSH/WSL; main adopts relayed rows as given): only the owner's hook events write the row, a different agent's events are guests that leave it unchanged, and ownership ends only on the owner's exit, a host check that it `exited`, or the freshness fallback for an owner the host cannot check. ## Agent Terminal Screens diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index cd8da3aecb4c..5549d042cbf5 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -104,20 +104,6 @@ describe('host-owned hook presence', () => { expect(visible(server)).toBe(false) }) - it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => { - const server = await createServer() - await hook(server, 'SessionStart', 'outer') - await hook(server, 'UserPromptSubmit', 'outer') - await hook(server, 'SessionStart', 'nested', undefined, 4002) - await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) - await hook(server, 'SessionEnd', 'nested', 'other', 4002) - expect(visible(server)).toBe(true) - await hook(server, 'PostToolUse', 'outer') - expect(state(server)).toBe('working') - await hook(server, 'SessionEnd', 'outer', 'other') - expect(visible(server)).toBe(false) - }) - it('never ends a pane from a SessionEnd without a process identity', async () => { const server = await createServer() await hook(server, 'SessionStart', 'outer', undefined, null) @@ -140,63 +126,72 @@ describe('host-owned hook presence', () => { expect(await server.checkAgentPresence(PANE)).toBeNull() }) - it('does not let a Claude started inside a working Codex turn end the pane', async () => { + it('adopts a relayed owner verdict as given, including its exit', async () => { const server = await createServer() const base = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } + const owner = { + agent: 'claude', + process: { pid: 4002, platform: 'linux', startTime: 'boot:1' } + } server.ingestRemote( { ...base, - source: 'codex', + source: 'claude', hookEventName: 'UserPromptSubmit', - payload: { state: 'working', prompt: 'codex task', agentType: 'codex' } + agentPresence: owner, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } }, 'ssh-1' ) - // The relay has no identity resolution, so it can hand the nested Claude ownership. + // Why: the relay decided this exit; main never re-checks it against its own copy of the owner. server.ingestRemote( { ...base, source: 'claude', hookEventName: 'SessionEnd', providerSessionOnly: true, - agentPresence: { - agent: 'claude', - process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, - ended: true - }, + agentPresence: { ...owner, ended: true }, payload: { state: 'done', prompt: '', agentType: 'claude' } }, 'ssh-1' ) - expect(state(server)).toBe('working') + expect(visible(server)).toBe(false) }) - it.each([ - ['working', []], - ['idle', ['Stop']] - ])('keeps a %s Codex pane when a Claude run inside it ends', async (_label, codexTail) => { + const codex = async (server: AgentHookServer, event: string): Promise => { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + } + + it('keeps a working Codex pane when a Claude run inside it ends', async () => { const server = await createServer() - const codex = async (event: string) => { - const response = await postHookEvent( - server, - buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), - '/hook/codex' - ) - expect(response.status).toBe(204) - } - await codex('UserPromptSubmit') - for (const event of codexTail) { - await codex(event) - } - const before = state(server) + await codex(server, 'UserPromptSubmit') await hook(server, 'SessionStart', 'nested', undefined, 4002) await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) await hook(server, 'SessionEnd', 'nested', 'other', 4002) - expect(visible(server)).toBe(true) - expect(before).not.toBeNull() + const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(row).toMatchObject({ state: 'working', agentType: 'codex', prompt: 'task' }) expect(await server.checkAgentPresence(PANE)).toBeNull() }) + // Why: Codex hooks report no process yet, so an idle Codex owner falls back to the freshness rule. + it('lets another agent take an idle owner the host cannot check', async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit') + await codex(server, 'Stop') + await hook(server, 'UserPromptSubmit', 'next', undefined, 4002) + expect(server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)).toMatchObject({ + state: 'working', + agentType: 'claude' + }) + await hook(server, 'SessionEnd', 'next', 'prompt_input_exit', 4002) + expect(visible(server)).toBe(false) + }) + it.each(['devin', 'qoder', 'codebuddy', 'copilot'])( 'settles %s to done on its own SessionEnd hook', async (agent) => { diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index 67f11a51a2bd..897fcca5cedf 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -194,131 +194,41 @@ describe('AgentHookServer ingestRemote', () => { ) }) - it('preserves active pane identity when a nested remote hook reports another agent', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'working', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md' - } - }, - 'conn-1' - ) - - expect(server.getStatusSnapshot()).toEqual([ - expect.objectContaining({ - paneKey: PANE, - state: 'working', - prompt: 'nested claude', - agentType: 'codex', - toolName: 'Read', - toolInput: '00-review-context.md', - receivedAt: 1_100 - }) - ]) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - prompt: 'nested claude', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('ignores nested remote done while the parent pane agent is still active', () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - try { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } - }, - 'conn-1' - ) - - vi.setSystemTime(1_100) - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - hasExplicitPrompt: true, - payload: { - state: 'done', - prompt: 'nested claude', - agentType: 'claude', - toolName: 'Read', - toolInput: '00-review-context.md', - lastAssistantMessage: 'child finished' - } - }, - 'conn-1' - ) - - const snapshot = server.getStatusSnapshot() - expect(snapshot).toHaveLength(1) - expect(snapshot[0]).toMatchObject({ + // Why: the relay classifies its panes' producers (a nested agent is a guest there and never + // forwarded), so main adopts every relayed row as given and never re-guesses its identity. + it('adopts a relayed row as its relay built it', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, - state: 'working', - prompt: 'parent codex', - agentType: 'codex', - receivedAt: 1_000, - stateStartedAt: 1_000 + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'codex', session: 'codex-a' }, + payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'claude', session: 'claude-a' }, + payload: { state: 'working', prompt: 'claude now', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'claude now', agentType: 'claude' }) + ]) + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentPresence: { agent: 'claude', session: 'claude-a' }, + payload: expect.objectContaining({ agentType: 'claude' }) }) - expect(snapshot[0].toolName).toBeUndefined() - expect(snapshot[0].toolInput).toBeUndefined() - expect(snapshot[0].lastAssistantMessage).toBeUndefined() - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenLastCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - state: 'working', - prompt: 'parent codex', - agentType: 'codex' - }) - }) - ) - expect(trackMock).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } + ) }) it('allows remote pane identity to change after the prior turn is done', () => { diff --git a/src/main/agent-hooks/server-pane-owner-relayed.test.ts b/src/main/agent-hooks/server-pane-owner-relayed.test.ts new file mode 100644 index 000000000000..9cd413ef00d6 --- /dev/null +++ b/src/main/agent-hooks/server-pane-owner-relayed.test.ts @@ -0,0 +1,283 @@ +// The pane owner rule on SSH/WSL panes: the relay classifies each hook producer and forwards only +// the rows it builds; the desktop adopts them as given. +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-freshness' +import { AgentHookServer } from './server' +import { buildBody, PANE } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const running: { stop: () => void }[] = [] +const paths: string[] = [] +afterEach(() => { + for (const server of running.splice(0)) { + server.stop() + } + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + probe.mockReset() + probe.mockResolvedValue('live') +}) + +type SshPane = { + relay: RelayAgentHookServer + desktop: AgentHookServer + pushed: AgentHookEventPayload[] + connected: boolean + post: (path: string, payload: Record, agentProcess?: string) => Promise +} + +async function startSshPane(): Promise { + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-pane-owner-relayed-')) + paths.push(endpointDir) + const desktop = new AgentHookServer() + const pane: SshPane = { + desktop, + pushed: [], + connected: true, + relay: new RelayAgentHookServer({ + endpointDir, + token: 'pane-owner-token', + forward: (envelope) => { + if (pane.connected) { + pane.desktop.ingestRemote(envelope, 'ssh-1') + } + } + }), + post: async (path, payload, agentProcess) => { + const { port, token } = pane.relay.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify(buildBody(payload, agentProcess ? { agentProcess } : {})) + }) + expect(response.status).toBe(204) + } + } + desktop.setListener((event) => pane.pushed.push(event)) + running.push(pane.relay, desktop) + await pane.relay.start({ publishEndpoint: false }) + return pane +} + +const CLAUDE_PROCESS = JSON.stringify({ pid: 4001, platform: 'linux', startTime: 'boot:1' }) + +function claude(pane: SshPane, event: string, extra: Record = {}) { + return pane.post( + '/hook/claude', + { hook_event_name: event, session_id: 'claude-a', ...extra }, + CLAUDE_PROCESS + ) +} + +function codex(pane: SshPane, event: string, extra: Record = {}) { + return pane.post('/hook/codex', { + hook_event_name: event, + session_id: 'codex-x', + model: 'gpt-6-astra', + ...extra + }) +} + +function row(server: AgentHookServer) { + return server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) +} + +describe('pane owner rule (relayed panes)', () => { + it('keeps Claude the owner while a nested Codex reports into its pane (STA-9582)', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(pane, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + await codex(pane, 'Stop') + await claude(pane, 'Stop') + // Prompt after Claude's Stop is the relay listener's pane-wide cache (a separate change). + expect(row(pane.desktop)).toMatchObject({ + state: 'done', + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + expect(pane.pushed.length).toBeGreaterThan(0) + for (const event of pane.pushed) { + expect(event.connectionId).toBe('ssh-1') + expect(event.payload.agentType).toBe('claude') + expect(event.payload.model).toBeUndefined() + expect(event.providerSession?.id).toBe('claude-a') + } + }) + + it('keeps a working Codex pane when a Claude run inside it ends (#23947)', async () => { + const pane = await startSshPane() + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await claude(pane, 'SessionStart', { source: 'startup' }) + await claude(pane, 'UserPromptSubmit', { prompt: 'nested claude' }) + await claude(pane, 'SessionEnd', { reason: 'prompt_input_exit' }) + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'codex', + prompt: 'codex task' + }) + expect(row(pane.desktop)?.providerSessionOnly).toBeUndefined() + }) + + it('hands the pane to the guest once the relay proves the owner exited', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + await claude(pane, 'Stop') + probe.mockResolvedValue('exited') + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => + expect(row(pane.desktop)).toMatchObject({ + state: 'working', + agentType: 'codex', + providerSession: { id: 'codex-x' } + }) + ) + expect(probe).toHaveBeenCalledOnce() + }) + + it('gives a replayed row the live verdict, and a disconnect releases nothing', async () => { + const pane = await startSshPane() + await claude(pane, 'UserPromptSubmit', { prompt: 'claude task' }) + const live = pane.pushed.at(-1)?.agentPresence + pane.connected = false + pane.desktop.clearStatusEntriesForConnection('ssh-1') + await codex(pane, 'UserPromptSubmit', { prompt: 'codex task' }) + pane.connected = true + pane.relay.replayCachedPayloadsForPanes() + const replayed = row(pane.desktop) + expect(replayed).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + expect(pane.pushed.at(-1)?.isReplay).toBe(true) + expect(pane.pushed.at(-1)?.agentPresence).toEqual(live) + expect(pane.pushed.at(-1)?.agentPresence).toMatchObject({ + agent: 'claude', + session: 'claude-a', + process: { pid: 4001 } + }) + }) + + it("keeps a relayed owner's agent type and model for a terminal signal that names no agent", () => { + const desktop = new AgentHookServer() + desktop.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { agent: 'codex', session: 'codex-x' }, + payload: { state: 'working', prompt: 'codex task', agentType: 'codex', model: 'gpt-5.4' } + }, + 'ssh-1' + ) + desktop.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + payload: { state: 'done', prompt: '', agentType: 'unknown' } + }) + expect(row(desktop)).toMatchObject({ state: 'done', agentType: 'codex', model: 'gpt-5.4' }) + }) + + // Why: main cannot check a remote process, so a relayed owner falls back to freshness here. + it('lets a terminal signal past a relayed owner only once the owner goes stale', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(1_000_000) + const desktop = new AgentHookServer() + const signal = () => + desktop.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-1', + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + desktop.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: { + agent: 'claude', + process: { pid: 4001, platform: 'linux', startTime: 'b' } + }, + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + signal() + expect(row(desktop)).toMatchObject({ agentType: 'claude' }) + expect(probe).not.toHaveBeenCalled() + vi.setSystemTime(1_000_000 + AGENT_STATUS_STALE_AFTER_MS + 1) + signal() + expect(row(desktop)).toMatchObject({ agentType: 'opencode' }) + } finally { + vi.useRealTimers() + } + }) + + it("persists a relayed row's owner and session, and restores it", async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-pane-owner-persist-')) + paths.push(userDataPath) + const owner = { + agent: 'claude', + process: { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' }, + session: 'claude-a' + } + const first = new AgentHookServer() + running.push(first) + await first.start({ env: 'production', userDataPath }) + first.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + agentPresence: owner, + providerSession: { key: 'session_id', id: 'claude-a' }, + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + first.flushStatusPersistSync() + first.stop() + const restored = new AgentHookServer() + running.push(restored) + await restored.start({ env: 'production', userDataPath }) + // Why: a relayed restatement with no owner keeps the restored one, so this reads it back. + const pushed: AgentHookEventPayload[] = [] + restored.setListener((event) => pushed.push(event)) + restored.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'claude task', agentType: 'claude' } + }, + 'ssh-1' + ) + expect(pushed.at(-1)?.agentPresence).toEqual(owner) + }) +}) diff --git a/src/main/agent-hooks/server-pane-owner.test.ts b/src/main/agent-hooks/server-pane-owner.test.ts new file mode 100644 index 000000000000..db457b519977 --- /dev/null +++ b/src/main/agent-hooks/server-pane-owner.test.ts @@ -0,0 +1,342 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'live') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const servers: AgentHookServer[] = [] +const paths: string[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + probe.mockReset() + probe.mockResolvedValue('live') +}) + +class OwnerTestServer extends AgentHookServer { + /** Stands in for a hook transport that reports `nestedIn` (a later change wires the env markers). */ + applyHook(event: AgentHookEventPayload): void { + this.applyNormalizedStatus(event) + } +} + +async function createServer(userDataPath?: string): Promise { + const server = new OwnerTestServer() + servers.push(server) + await server.start({ env: 'production', ...(userDataPath ? { userDataPath } : {}) }) + return server +} + +async function claude( + server: AgentHookServer, + event: string, + extra: Record = {}, + session = 'claude-a', + pid: number | null = 4001 +): Promise { + const agentProcess = + pid === null + ? undefined + : JSON.stringify({ pid, platform: process.platform, startTime: `birth-${pid}` }) + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: session, ...extra }, { agentProcess }) + ) + expect(response.status).toBe(204) +} + +async function codex( + server: AgentHookServer, + event: string, + extra: Record = {} +): Promise { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-x', model: 'gpt-6-astra', ...extra }), + '/hook/codex' + ) + expect(response.status).toBe(204) +} + +function row(server: AgentHookServer) { + return server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) +} + +describe('pane owner rule (local panes)', () => { + it('keeps Claude the owner while a nested Codex reports into its pane (STA-9582)', async () => { + const server = await createServer() + const pushed: AgentHookEventPayload[] = [] + server.setListener((event) => pushed.push(event)) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(server, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + expect(row(server)).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'claude task', + providerSession: { id: 'claude-a' } + }) + await codex(server, 'Stop') + await claude(server, 'Stop') + // Prompt after Claude's Stop is the listener's pane-wide cache (a separate change), so not asserted. + expect(row(server)).toMatchObject({ + state: 'done', + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + expect(row(server)?.model).toBeUndefined() + expect(pushed.length).toBeGreaterThan(0) + for (const event of pushed) { + expect(event.payload.agentType).toBe('claude') + expect(event.payload.model).toBeUndefined() + expect(event.providerSession?.id).toBe('claude-a') + } + }) + + it('keeps an idle Claude the owner while its process lives, then takes its next turn', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'first' }) + await claude(server, 'Stop') + await codex(server, 'UserPromptSubmit', { prompt: 'detached codex' }) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + await claude(server, 'UserPromptSubmit', { prompt: 'second' }) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude', prompt: 'second' }) + await codex(server, 'Stop') + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude' }) + await claude(server, 'Stop') + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('checks the owner once however many guest events arrive', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + for (let index = 0; index < 6; index += 1) { + await codex(server, 'PostToolUse', { tool_name: 'Bash', tool_input: { command: 'ls' } }) + } + await vi.waitFor(() => expect(probe).toHaveBeenCalled()) + expect(probe).toHaveBeenCalledOnce() + }) + + it('hands the pane to the guest whose event proved the owner exited', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'first' }) + await claude(server, 'Stop') + probe.mockResolvedValue('exited') + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => + expect(row(server)).toMatchObject({ + state: 'working', + agentType: 'codex', + prompt: 'codex task', + providerSession: { id: 'codex-x' } + }) + ) + }) + + it('never replays a guest onto an owner that ended on its own exit', async () => { + const server = await createServer() + let finish: (verdict: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + await claude(server, 'SessionEnd', { reason: 'prompt_input_exit' }) + finish('exited') + await new Promise((resolve) => setTimeout(resolve, 20)) + const remnant = row(server) + expect(remnant).toMatchObject({ + providerSessionOnly: true, + agentType: 'claude', + providerSession: { id: 'claude-a' } + }) + }) + + it('treats a same-type session without proof as the owner, but only its own exit ends it', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'outer' }) + await claude(server, 'UserPromptSubmit', { prompt: 'inner' }, 'claude-b', 4002) + expect(row(server)).toMatchObject({ agentType: 'claude', prompt: 'inner' }) + await claude(server, 'SessionEnd', { reason: 'other' }, 'claude-b', 4002) + expect(row(server)?.providerSessionOnly).toBeUndefined() + await claude(server, 'SessionEnd', { reason: 'other' }) + expect(row(server)?.providerSessionOnly).toBe(true) + }) + + // Why no process: an owner the host cannot check yields to liveness, so only proof keeps it here. + it('keeps the owner session across /clear and recognizes the old one', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'before clear' }, 'claude-a', null) + await claude(server, 'SessionEnd', { reason: 'clear' }, 'claude-a', null) + await claude(server, 'UserPromptSubmit', { prompt: 'after clear' }, 'claude-b', null) + await claude(server, 'Stop', {}, 'claude-b', null) + expect(row(server)).toMatchObject({ state: 'done', providerSession: { id: 'claude-b' } }) + server.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + const osc = (server: AgentHookServer, agentType: string, state: 'working' | 'done') => + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state, prompt: 'osc', agentType } + }) + + it("leaves a held owner's row unchanged when a terminal signal names another agent", async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + const before = row(server) + osc(server, 'codex', 'done') + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + expect(row(server)).toEqual(before) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'claude', prompt: 'task' }) + }) + + it('writes the next terminal signal once the check releases the owner', async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + probe.mockResolvedValue('exited') + osc(server, 'codex', 'working') + await vi.waitFor(() => expect(row(server)?.providerSessionOnly).toBe(true)) + osc(server, 'codex', 'working') + expect(row(server)).toMatchObject({ state: 'working', agentType: 'codex', prompt: 'osc' }) + }) + + it("lets a terminal signal naming the owner's type update its state", async () => { + const server = await createServer() + await claude(server, 'UserPromptSubmit', { prompt: 'task' }) + osc(server, 'claude', 'done') + expect(row(server)).toMatchObject({ state: 'done', agentType: 'claude' }) + expect(probe).not.toHaveBeenCalled() + }) + + it('hands the pane to a guest held while a terminal signal had already started the check', async () => { + const server = await createServer() + let finish: (verdict: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + osc(server, 'codex', 'working') + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + expect(probe).toHaveBeenCalledOnce() + finish('exited') + await vi.waitFor(() => + expect(row(server)).toMatchObject({ agentType: 'codex', prompt: 'codex task' }) + ) + }) + + it('leaves a released owner behind when a terminal signal names another agent', async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + await codex(server, 'Stop') + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + origin: 'process', + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'opencode' }) + const response = await postHookEvent( + server, + buildBody({ hook_event_name: 'SessionBusy', sessionID: 'ses_1' }), + '/hook/opencode' + ) + expect(response.status).toBe(204) + expect(row(server)).toMatchObject({ state: 'working', agentType: 'opencode' }) + expect(row(server)?.observation?.origin).toBe('hook') + await codex(server, 'UserPromptSubmit', { prompt: 'late codex' }) + expect(row(server)).toMatchObject({ agentType: 'opencode' }) + }) + + it("keeps the owner's agent type and model for signals that name no agent", async () => { + const server = await createServer() + await codex(server, 'UserPromptSubmit', { prompt: 'codex task' }) + expect(row(server)).toMatchObject({ agentType: 'codex', model: 'gpt-6-astra' }) + osc(server, 'unknown', 'working') + expect(row(server)).toMatchObject({ agentType: 'codex', model: 'gpt-6-astra' }) + server.applyHook({ + paneKey: PANE, + connectionId: null, + providerSession: { key: 'session_id', id: 'codex-x' }, + payload: { state: 'done', prompt: 'codex task', agentType: 'unknown' } + }) + expect(row(server)).toMatchObject({ state: 'done', agentType: 'codex', model: 'gpt-6-astra' }) + }) + + it('never lets a terminal signal claim an ownerless pane', async () => { + const server = await createServer() + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'osc', agentType: 'codex' } + }) + expect(row(server)).toMatchObject({ agentType: 'codex' }) + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + expect(row(server)).toMatchObject({ agentType: 'claude', prompt: 'claude task' }) + }) + + it('never inverts a restored owner when its nested producer reports first', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-pane-owner-')) + paths.push(userDataPath) + const first = await createServer(userDataPath) + await claude(first, 'UserPromptSubmit', { prompt: 'claude task' }) + first.flushStatusPersistSync() + first.stop() + const restarted = await createServer(userDataPath) + expect(row(restarted)).toMatchObject({ agentType: 'claude', restoredUnconfirmed: true }) + restarted.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(restarted)).toMatchObject({ agentType: 'claude', prompt: 'claude task' }) + await claude(restarted, 'Stop') + expect(row(restarted)).toMatchObject({ state: 'done', agentType: 'claude' }) + }) + + it('never lets a nested producer claim an ownerless pane', async () => { + const server = await createServer() + server.applyHook({ + paneKey: PANE, + connectionId: null, + nestedIn: [{ agent: 'claude', session: 'claude-a' }], + payload: { state: 'working', prompt: 'nested codex', agentType: 'codex' } + }) + expect(row(server)).toBeUndefined() + await claude(server, 'UserPromptSubmit', { prompt: 'claude task' }) + expect(row(server)).toMatchObject({ agentType: 'claude' }) + }) +}) diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index ec09832ccb85..bbb849d75a68 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,10 +1,8 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import { - isSameAgentProcess, - type AgentProcessVerdict -} from '../../../shared/agent-process-presence' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' import { AgentHookServerLifecycle } from './server-lifecycle' +import { currentOwner } from '../../../shared/agent-hook-presence-transition' export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { private readonly presenceChecks = new WeakMap< @@ -12,28 +10,24 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy Promise >() - /** A live hook proves its own process alive; only another process's hook casts doubt on the owner. */ - checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { - const sender = event.agentPresence?.process - const owner = row.agentPresence - if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { - void this.checkAgentPresence(row.paneKey) - } - } - /** Whether this pane's owner carries a process identity that its execution host can check. */ hasVerifiableAgentProcess(paneKey: string): boolean { - const presence = this.state.lastStatusByPaneKey.get( - this.resolvePaneKeyAlias(paneKey) - )?.agentPresence - return presence?.process !== undefined && !presence.ended + return ( + currentOwner(this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey))) + ?.process !== undefined + ) } checkAgentPresence(paneKey: string): Promise { + return this.paneOwnerProbes.check(paneKey) + } + + protected probeOwnerProcess(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) + const presence = currentOwner(row) // Why: an ended owner already published its exit, and an owner no hook identified cannot be checked. - if (!row?.agentPresence?.process || row.agentPresence.ended) { + if (!row || !presence?.process) { return Promise.resolve(null) } if (row.connectionId !== null) { @@ -43,13 +37,9 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy if (pending) { return pending } - const presence = row.agentPresence const check = probeAgentProcessPresence(presence.process) .then((verdict) => { - if ( - this.state.lastStatusByPaneKey.get(resolved) !== row || - row.agentPresence !== presence - ) { + if (this.state.lastStatusByPaneKey.get(resolved) !== row) { return 'unverifiable' as const } if (verdict === 'exited') { diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 004a6462d27b..d715ab9de4f8 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -5,6 +5,10 @@ import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { + classifyTerminalSignal, + withOwnerAgentType +} from '../../../shared/agent-hook-presence-transition' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -104,8 +108,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.yieldsToHookSince !== undefined && previous?.observation?.origin === 'hook' && previous.receivedAt >= event.yieldsToHookSince + // Why: main cannot check a remote owner; its relay does. + const signal = classifyTerminalSignal(previous, event.payload.agentType, connectionId === null) + if (signal.kind === 'yield' && signal.probe) { + this.paneOwnerProbes.probe(paneKey, signal.probe) + } if ( hookOwnsCommand || + signal.kind === 'yield' || (previous?.payload.agentType === 'claude' && event.payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && @@ -164,19 +174,26 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) ? previous.payload.mainAgent : undefined + const owner = signal.kind === 'write' ? signal.owner : undefined + // Why: a terminal signal that names no model leaves the owner's. + const preservedModel = owner && !event.payload.model ? previous?.payload.model : undefined + const row = { + paneKey, + tabId, + worktreeId, + connectionId, + agentPresence: owner, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + payload: { + ...event.payload, + ...(preservedMainAgent ? { mainAgent: preservedMainAgent } : {}), + ...(preservedModel ? { model: preservedModel } : {}) + } + } // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - payload: preservedMainAgent - ? { ...event.payload, mainAgent: preservedMainAgent } - : event.payload - }, + owner ? withOwnerAgentType(row, owner) : row, undefined, event.origin ?? 'osc', undefined, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 4a8c54fe9357..c560d11362f7 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -143,7 +143,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { - this.checkAgentPresenceAfterHook(event, enriched) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index db61f046e24d..ecf1957c95d6 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -272,10 +272,7 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean abstract checkAgentPresence(paneKey: string): Promise - abstract checkAgentPresenceAfterHook( - event: AgentHookEventPayload, - row: AgentHookEventPayload - ): void + protected abstract probeOwnerProcess(paneKey: string): Promise abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 03ec7c778503..a1e077ba5260 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,12 +1,13 @@ -import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' +import { + adoptRelayedRow, + currentOwner, + transitionHookPresence +} from '../../../shared/agent-hook-presence-transition' +import { PaneOwnerProbes } from '../../../shared/agent-pane-owner-probes' import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../shared/agent-status-identity' import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' @@ -27,6 +28,12 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.state ) + protected readonly paneOwnerProbes = new PaneOwnerProbes({ + ownerOf: (paneKey) => + currentOwner(this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey)))?.process, + checkOwner: (paneKey) => this.probeOwnerProcess(paneKey) + }) + // Why here: every stored row passes through, including a cancel inference and a pane move. protected override commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, @@ -50,14 +57,43 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const transitioned = transitionHookPresence( - incoming, - this.state.lastStatusByPaneKey.get(incoming.paneKey) - ) - if (!transitioned) { - return undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const previous = this.state.lastStatusByPaneKey.get(incoming.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const apply = (event: typeof incoming) => + this.applyOwnedStatus(event, previous, onAccepted, origin, observedAt, mutationBefore) + // Why: terminal ingest already placed its signal under the owner (terminals never claim); a + // relayed row's owner was decided by its relay; main decides only for local hook producers. + if (origin !== 'hook') { + return apply(incoming) + } + if (incoming.connectionId !== null) { + return apply(adoptRelayedRow(incoming, previous)) } - const { authorityRestartId, ...payload } = { ...incoming, ...transitioned } + return this.paneOwnerProbes.admit( + incoming.paneKey, + transitionHookPresence(incoming, previous, previous?.receivedAt), + { + write: apply, + reapply: () => { + if (this.server) { + this.applyNormalizedStatus(incoming, onAccepted) + } + } + } + ) + } + + private applyOwnedStatus( + incoming: AgentHookEventPayload & { authorityRestartId?: string }, + previous: EnrichedAgentHookEventPayload | undefined, + onAccepted: (() => void) | undefined, + origin: AgentStatusObservationOrigin, + observedAt: number | undefined, + mutationBefore: EnrichedAgentHookEventPayload | undefined + ): EnrichedAgentHookEventPayload | undefined { + const { authorityRestartId, ...payload } = incoming if (!this.canWriteLegacyStatusRow(payload)) { return undefined } @@ -72,10 +108,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined const rowBefore = mutationBefore ?? previous const terminalHandle = payload.terminalHandle ?? @@ -136,59 +168,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA ) } : terminalOwnedPayload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - this.commitStatusRowMutation(rowBefore, previous) - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } - } - const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const attachedPayload = attachClaudePermissionToolUseId(previous, stateReconciledPayload) // Why before the permission hold: that hold adopts the event's `mainAgent`, and a relay's // restatement of a main agent the desktop cancelled must not replace the cancel. const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) @@ -240,9 +220,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.clearAssistantMessageRetry(effectivePayload.paneKey) } onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } + this.maybeTrackAgentPromptSent(effectivePayload, previous) // Why carried forward only within one host: main's OSC parse resolves the handle, so a later // hook must not erase its terminal join; a connection change must not inherit another host's. const { claudeRunningNonAgentTask: _unpaired, ...unpairedPayload } = effectivePayload diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 19dac11ffd1b..7afba3e2eefc 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -306,6 +306,51 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it("adopts the relay's pane owner, including its exit", async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-owner') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Mocked deployment never reads the connection. + await session.establish({} as SshConnection) + const owner = { + agent: 'claude', + process: { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' }, + session: 'claude-a' + } + const base = { + source: 'claude' as const, + agentPresence: owner, + providerSession: { key: 'session_id' as const, id: 'claude-a' } + } + relay.notifyAgentHook( + makeEnvelope({ + ...base, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + }) + ) + await waitForStatusCount(events, 1) + const paneKey = `tab-ssh:${SSH_LEAF_ID}` + const live = () => agentHookServer.getStatusSnapshot().find((row) => row.paneKey === paneKey) + expect(live()).toMatchObject({ state: 'working', agentType: 'claude' }) + relay.notifyAgentHook( + makeEnvelope({ + ...base, + hookEventName: 'AgentProcessExit', + providerSessionOnly: true, + agentPresence: { ...owner, ended: true }, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + }) + ) + await vi.waitFor(() => expect(live()?.providerSessionOnly).toBe(true)) + expect(live()?.providerSession).toEqual({ key: 'session_id', id: 'claude-a' }) + }) + it('preserves tmux evidence age and unavailable across the real notification adapter', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index e7f7c60a15c0..0b3304093ae1 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1772,6 +1772,8 @@ export class SshRelaySession { agentHookServer.ingestRemote( { paneKey: envelope.paneKey, + // Why: the relay decides the pane's owner; ingestRemote validates and adopts it. + agentPresence: envelope.agentPresence, hostTurnRevision: envelope.hostTurnRevision, launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, diff --git a/src/relay/agent-hook-event-admission.ts b/src/relay/agent-hook-event-admission.ts index 50cb84adccd5..d9c94f5aac70 100644 --- a/src/relay/agent-hook-event-admission.ts +++ b/src/relay/agent-hook-event-admission.ts @@ -1,5 +1,5 @@ import { transitionHookPresence } from '../shared/agent-hook-presence-transition' -import { isSameAgentProcess } from '../shared/agent-process-presence' +import type { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache' import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' @@ -20,7 +20,7 @@ type RelayHookAdmissionHost = { clearPaneState: (paneKey: string) => void clearAssistantMessageRetry: (paneKey: string) => void forward: RelayHookForward - checkAgentPresence: (paneKey: string) => Promise + ownerProbes: PaneOwnerProbes } export function applyRelayHookEvent( @@ -29,7 +29,7 @@ export function applyRelayHookEvent( source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean } = {} + options: { isReplay?: boolean } = {} ): AgentHookEventPayload | undefined { if (host.isCanonicalPane(incoming.paneKey)) { return undefined @@ -39,10 +39,29 @@ export function applyRelayHookEvent( if (cancellation.hold) { return previous } - const transitioned = transitionHookPresence(cancellation.event, previous) - if (!transitioned) { - return undefined - } + // Why: the relay decides for its panes and forwards only rows, so a guest event forwards nothing. + return host.ownerProbes.admit( + incoming.paneKey, + transitionHookPresence(cancellation.event, previous, previous?.hostEvidenceObservedAt), + { + write: (transitioned) => + writeRelayRow(host, previous, transitioned, source, env, version, options), + reapply: () => { + applyRelayHookEvent(host, incoming, source, env, version, options) + } + } + ) +} + +function writeRelayRow( + host: RelayHookAdmissionHost, + previous: AgentHookEventPayload | undefined, + transitioned: AgentHookEventPayload, + source: AgentHookSource, + env: string | undefined, + version: string | undefined, + options: { isReplay?: boolean } +): AgentHookEventPayload | undefined { const event = withRelayClaudeTurnRevision( previous, transitioned.agentPresence?.ended @@ -74,18 +93,6 @@ export function applyRelayHookEvent( host.metadata.delete(event.paneKey) host.metadata.set(event.paneKey, { source, env, version }) host.forward(buildRelayHookEnvelope(event, source, env, version, options)) - const sender = incoming.agentPresence?.process - const owner = event.agentPresence - // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. - if ( - options.checkPresence !== false && - sender && - owner?.process && - !owner.ended && - !isSameAgentProcess(sender, owner.process) - ) { - void host.checkAgentPresence(event.paneKey) - } // Why: retries compare against the cached row by identity, so they must hold that exact row. return host.state.lastStatusByPaneKey.get(event.paneKey) } diff --git a/src/relay/agent-hook-loopback-listener.ts b/src/relay/agent-hook-loopback-listener.ts new file mode 100644 index 000000000000..20421f6f0ab0 --- /dev/null +++ b/src/relay/agent-hook-loopback-listener.ts @@ -0,0 +1,22 @@ +import type { Server } from 'node:http' + +/** Binds the relay hook server on loopback only — reachable by the in-box agent CLI (127.0.0.1), + * not from outside the box. Resolves the bound port when the address reports one. */ +export function listenOnLoopback(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + server.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + server.off('error', onStartupError) + server.on('error', (err) => { + process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) + }) + const address = server.address() + resolve(address && typeof address === 'object' ? address.port : undefined) + } + server.once('error', onStartupError) + server.listen(port, '127.0.0.1', onListening) + }) +} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 0250f8a87b6c..d3d1150edd52 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -15,7 +15,11 @@ export type { RelayHookServerStartOptions } from './agent-hook-server-contract' import { handleRelayHookRequest } from './agent-hook-request' +import { listenOnLoopback } from './agent-hook-loopback-listener' import { RelayAgentPresence } from './relay-agent-presence' +import { PaneOwnerProbes } from '../shared/agent-pane-owner-probes' +import { currentOwner } from '../shared/agent-hook-presence-transition' +import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -72,7 +76,20 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false - private readonly presenceChecks = new RelayAgentPresence() + private readonly presenceChecks = new RelayAgentPresence({ + current: (paneKey) => this.state.lastStatusByPaneKey.get(paneKey), + publish: (paneKey, event) => { + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + return ( + meta !== undefined && + this.applyEvent(event, meta.source, meta.env, meta.version) !== undefined + ) + } + }) + private readonly ownerProbes = new PaneOwnerProbes({ + ownerOf: (paneKey) => currentOwner(this.state.lastStatusByPaneKey.get(paneKey))?.process, + checkOwner: (paneKey) => this.presenceChecks.check(paneKey) + }) private retryScheduler: AgentHookResultRetryScheduler readonly claudeTerminalInterrupts = createRelayClaudeTerminalInterrupts(this.state, () => this.relayInterruptHost() @@ -103,7 +120,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { env: this.env, isListening: () => this.server !== null, applyEvent: (event, source, env, version) => { - this.applyEvent(event, source, env, version, { checkPresence: false }) + this.applyEvent(event, source, env, version) } }) } @@ -137,30 +154,15 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { return this.portFallbackApplied } - private listenOn(port: number): Promise { + private async listenOn(port: number): Promise { this.server = createServer((req, res) => this.handleRequest(req, res)) - return new Promise((resolve, reject) => { - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - // Why: clear failed server refs so later start() calls can retry. - this.server = null - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - resolve() - } - this.server!.once('error', onStartupError) - // Why: loopback only — reachable by the in-box agent CLI (127.0.0.1), not from outside the box. - this.server!.listen(port, '127.0.0.1', onListening) - }) + try { + this.port = (await listenOnLoopback(this.server, port)) ?? this.port + } catch (err) { + // Why: clear failed server refs so later start() calls can retry. + this.server = null + throw err + } } publishEndpointFile(): boolean { @@ -223,8 +225,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { getAgentLaunchToken: this.getAgentLaunchToken, isPaneBlocked: (paneKey) => this.isCanonicalPane(paneKey) || this.isPaneSurfaceRetired(paneKey), - apply: (event, meta) => - this.applyEvent(event, meta.source, meta.env, meta.version, { checkPresence: false }), + apply: (event, meta) => this.applyEvent(event, meta.source, meta.env, meta.version), armExpiry: (paneKey, meta) => this.retryScheduler.armClaudeOwedNotificationExpiry( meta.source, @@ -235,18 +236,8 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { } } - checkAgentPresence(paneKey: string): Promise { - const row = this.state.lastStatusByPaneKey.get(paneKey) - const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) - return this.presenceChecks.check( - row, - () => this.state.lastStatusByPaneKey.get(paneKey), - (event) => { - if (meta) { - this.applyEvent(event, meta.source, meta.env, meta.version) - } - } - ) + checkAgentPresence(paneKey: string): Promise { + return this.ownerProbes.check(paneKey) } /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ @@ -298,7 +289,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean; checkPresence?: boolean } = {} + options: { isReplay?: boolean } = {} ): AgentHookEventPayload | undefined { return applyRelayHookEvent( { @@ -310,7 +301,7 @@ export class RelayAgentHookServer extends RelayAgentHookCanonicalStatus { clearAssistantMessageRetry: (paneKey) => this.retryScheduler.clearAssistantMessageRetry(paneKey), forward: this.forward, - checkAgentPresence: (paneKey) => this.checkAgentPresence(paneKey) + ownerProbes: this.ownerProbes }, incoming, source, diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts index f32278a1309d..91f7fd74efca 100644 --- a/src/relay/relay-agent-presence.ts +++ b/src/relay/relay-agent-presence.ts @@ -1,31 +1,47 @@ import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentProcessVerdict } from '../shared/agent-process-presence' import { probeAgentProcessPresence } from '../shared/agent-process-presence-probe' +import { currentOwner } from '../shared/agent-hook-presence-transition' export class RelayAgentPresence { - private readonly pending = new WeakMap>() + constructor( + private readonly host: { + current: (paneKey: string) => AgentHookEventPayload | undefined + /** Applies the exit; false when the pane could not take it (no cached envelope metadata). */ + publish: (paneKey: string, event: AgentHookEventPayload) => boolean + } + ) {} + + private readonly pending = new WeakMap< + AgentHookEventPayload, + Promise + >() - check( - row: AgentHookEventPayload | undefined, - current: () => AgentHookEventPayload | undefined, - publish: (event: AgentHookEventPayload) => void - ): Promise { - if (!row?.agentPresence?.process || row.agentPresence.ended) { - return Promise.resolve() + check(paneKey: string): Promise { + const row = this.host.current(paneKey) + const presence = currentOwner(row) + if (!row || !presence?.process) { + return Promise.resolve(null) } const existing = this.pending.get(row) if (existing) { return existing } - const presence = row.agentPresence const check = probeAgentProcessPresence(presence.process) .then((verdict) => { - if (verdict === 'exited' && current() === row) { - publish({ - ...row, - hookEventName: 'AgentProcessExit', - agentPresence: { ...presence, ended: true } - }) + // Why: `exited` means this check released the pane; a row that moved on was not released. + if (this.host.current(paneKey) !== row) { + return 'unverifiable' as const + } + if (verdict !== 'exited') { + return verdict } + const released = this.host.publish(paneKey, { + ...row, + hookEventName: 'AgentProcessExit', + agentPresence: { ...presence, ended: true } + }) + return released ? verdict : 'unverifiable' }) .finally(() => this.pending.delete(row)) this.pending.set(row, check) diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index e2e2303e3c23..b3a7bbac5db6 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -1,4 +1,4 @@ -import type { AgentProcessPresence } from '../agent-process-presence' +import type { AgentProcessPresence, NestedAgentSession } from '../agent-process-presence' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import type { AgentProviderSessionMetadata } from '../agent-session-resume' @@ -6,6 +6,8 @@ import type { AgentProviderSessionMetadata } from '../agent-session-resume' export type AgentHookEventPayload = { paneKey: string agentPresence?: AgentProcessPresence + /** Agent sessions the producing process runs inside; classification input only, never stored. */ + nestedIn?: readonly NestedAgentSession[] /** Authenticated hook route that produced this event. */ source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ diff --git a/src/shared/agent-hook-presence-transition.test.ts b/src/shared/agent-hook-presence-transition.test.ts new file mode 100644 index 000000000000..877c0ec0ae69 --- /dev/null +++ b/src/shared/agent-hook-presence-transition.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { transitionHookPresence } from './agent-hook-presence-transition' +import { readAgentProcessPresence } from './agent-process-presence' + +const NOW = 10_000_000 +const OWNER_PROCESS = { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' } + +function event( + agentType: string, + fields: Partial = {}, + state: 'working' | 'done' = 'working' +): AgentHookEventPayload { + return { + paneKey: 'pane', + connectionId: null, + payload: { state, prompt: '', agentType }, + ...fields + } +} + +function session(id: string) { + return { providerSession: { key: 'session_id' as const, id } } +} + +const owner = event( + 'claude', + { ...session('claude-a'), agentPresence: { agent: 'claude', session: 'claude-a' } }, + 'done' +) + +describe('transitionHookPresence', () => { + it("ignores a producer's own-type markers, which a nested agent overwrites", () => { + const own = event('codex', { + nestedIn: [{ agent: 'codex', session: 'claude-a' }] + }) + expect(transitionHookPresence(own, owner, NOW, NOW).kind).toBe('write') + expect(transitionHookPresence(own, undefined, undefined, NOW)).toMatchObject({ + kind: 'write', + event: { agentPresence: { agent: 'codex' } } + }) + }) + + it('never stores the nesting markers on the row', () => { + const transition = transitionHookPresence( + event('claude', { ...session('claude-a'), nestedIn: [{ agent: 'codex', session: 'x' }] }), + owner, + NOW, + NOW + ) + expect(transition.kind === 'write' && 'nestedIn' in transition.event).toBe(false) + }) + + it('makes a different-type producer a guest of a live owner and asks to check it', () => { + const live = event('claude', { + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' } + }) + expect(transitionHookPresence(event('codex', session('codex-x')), live, NOW, NOW)).toEqual({ + kind: 'skip', + probe: OWNER_PROCESS + }) + }) + + it('holds and checks only a live unproven guest; proof and a guest exit need no check', () => { + const live = event('claude', { + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' } + }) + const nested = event('codex', { nestedIn: [{ agent: 'claude', session: 'claude-a' }] }) + expect(transitionHookPresence(nested, live, NOW, NOW)).toEqual({ kind: 'skip' }) + const guestExit = event('codex', { + agentPresence: { agent: 'codex', process: { ...OWNER_PROCESS, pid: 4002 }, ended: true } + }) + expect(transitionHookPresence(guestExit, live, NOW, NOW)).toEqual({ kind: 'skip' }) + }) + + it("never carries a restarted process's predecessor model or session", () => { + const previous = event('claude', { + ...session('claude-a'), + agentPresence: { agent: 'claude', process: OWNER_PROCESS, session: 'claude-a' }, + payload: { state: 'done', prompt: '', agentType: 'claude', model: 'opus' } + }) + const restarted = event('claude', { + agentPresence: { agent: 'claude', process: { ...OWNER_PROCESS, pid: 4002 } } + }) + const transition = transitionHookPresence(restarted, previous, NOW, NOW) + expect(transition.kind === 'write' && transition.event.payload.model).toBeUndefined() + expect(transition.kind === 'write' && transition.event.providerSession).toBeUndefined() + }) + + it('bounds the sessions an owner remembers', () => { + let row: AgentHookEventPayload = owner + for (const id of ['b', 'c', 'd', 'e', 'f', 'g']) { + const transition = transitionHookPresence(event('claude', session(id)), row, NOW, NOW) + if (transition.kind !== 'write') { + throw new Error('owner event was not written') + } + row = transition.event + } + expect(row.agentPresence).toEqual({ + agent: 'claude', + session: 'g', + heldSessions: ['f', 'e', 'd', 'c'] + }) + expect(readAgentProcessPresence(JSON.parse(JSON.stringify(row.agentPresence)))).toEqual( + row.agentPresence + ) + }) + + it("carries the owner's model and session through its own sparse events", () => { + const withModel = event('codex', { + ...session('codex-x'), + agentPresence: { agent: 'codex', session: 'codex-x' }, + payload: { state: 'working', prompt: '', agentType: 'codex', model: 'gpt-5.4' } + }) + expect( + transitionHookPresence(event('codex', { toolAgentId: 'child' }), withModel, NOW, NOW) + ).toMatchObject({ + kind: 'write', + event: { providerSession: { id: 'codex-x' }, payload: { model: 'gpt-5.4' } } + }) + }) +}) diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 241ec382efaf..f78587bcada7 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,45 +1,266 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' -import { isSameAgentProcess } from './agent-process-presence' +import { + isSameAgentProcess, + MAX_OWNER_HELD_SESSIONS, + type AgentProcessIdentity, + type AgentProcessPresence +} from './agent-process-presence' +import { isFreshNonDoneAgentStatus } from './agent-status-freshness' -/** A pane has one owning agent; only the owner's own proven process can end it. */ +/** `write` stores the event; `skip` leaves the row unchanged. `probe` is an owner process another + * producer cast doubt on; on `skip` it also means the event is held until that owner is gone. */ +export type HookPresenceTransition = + | { kind: 'write'; event: AgentHookEventPayload; probe?: AgentProcessIdentity } + | { kind: 'skip'; probe?: AgentProcessIdentity } + +type HookProducer = { + agent: string | undefined + session?: string + process?: AgentProcessIdentity + /** Sessions of other agent types this producer runs inside (own-type markers prove nothing). */ + nestedIn: string[] +} + +/** The pane's owner. An ended owner, or an identity-only row (a resume remnant, a Pi session + * announcement), holds no pane. */ +export function currentOwner( + row: AgentHookEventPayload | undefined +): AgentProcessPresence | undefined { + return row?.agentPresence && !row.agentPresence.ended && !row.providerSessionOnly + ? row.agentPresence + : undefined +} + +function readHookProducer(incoming: AgentHookEventPayload): HookProducer { + const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType + return { + agent: agent && agent !== 'unknown' ? agent : undefined, + session: incoming.providerSession?.id, + process: incoming.agentPresence?.process, + nestedIn: (incoming.nestedIn ?? []) + .filter((entry) => entry.agent !== agent) + .map((entry) => entry.session) + } +} + +function ownerSessions(owner: AgentProcessPresence): string[] { + return owner.session ? [owner.session, ...(owner.heldSessions ?? [])] : (owner.heldSessions ?? []) +} + +function sameProcess( + a: AgentProcessIdentity | undefined, + b: AgentProcessIdentity | undefined +): boolean { + return a !== undefined && b !== undefined && isSameAgentProcess(a, b) +} + +function otherProcess( + a: AgentProcessIdentity | undefined, + b: AgentProcessIdentity | undefined +): boolean { + return a !== undefined && b !== undefined && !isSameAgentProcess(a, b) +} + +// Why: only an owner this host cannot check falls back to freshness; a restored one never blocks. +function isOwnerReleased( + owner: AgentProcessPresence, + row: AgentHookEventPayload, + rowUpdatedAt: number | undefined, + now: number, + checkable = true +): boolean { + return ( + row.restoredUnconfirmed === true || + ((!checkable || owner.process === undefined) && + !isFreshNonDoneAgentStatus({ state: row.payload.state, updatedAt: rowUpdatedAt ?? 0 }, now)) + ) +} + +/** An owned row's agent type is its owner's; an event that names no agent never erases it. */ +export function withOwnerAgentType( + event: AgentHookEventPayload, + owner: AgentProcessPresence +): AgentHookEventPayload { + const agentType = event.payload.agentType + return agentType && agentType !== 'unknown' + ? event + : { ...event, payload: { ...event.payload, agentType: owner.agent } } +} + +/** PLAN rules 1-4, first match wins: proven guests (rule 2) need no liveness check. */ +function classifyAgainstOwner( + producer: HookProducer, + owner: AgentProcessPresence, + previous: AgentHookEventPayload, + rowUpdatedAt: number | undefined, + now: number +): 'owner' | 'nested' | 'guest' | 'claim' { + const held = ownerSessions(owner) + if ( + (producer.session !== undefined && held.includes(producer.session)) || + sameProcess(producer.process, owner.process) + ) { + return 'owner' + } + if (producer.nestedIn.some((session) => held.includes(session))) { + return 'nested' + } + if (producer.agent === undefined || producer.agent === owner.agent) { + return 'owner' + } + return isOwnerReleased(owner, previous, rowUpdatedAt, now) ? 'claim' : 'guest' +} + +function withOwnerSession( + owner: AgentProcessPresence, + producer: HookProducer +): AgentProcessPresence { + const session = producer.session + // Why: a different process of the owner's type, or a producer naming no agent, never rotates it. + if ( + !session || + !producer.agent || + session === owner.session || + otherProcess(producer.process, owner.process) + ) { + return owner + } + const heldSessions = ownerSessions(owner) + .filter((held) => held !== session) + .slice(0, MAX_OWNER_HELD_SESSIONS) + return { ...owner, session, ...(heldSessions.length > 0 ? { heldSessions } : {}) } +} + +/** The owner's own sparse events (child hooks, a relay that restarted) keep its model and session; + * a restarted process of the same type starts clean. */ +function carryOwnerFields( + event: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload { + // Why: a row with no owner record still carries (older relays' envelopes send no agentPresence). + if ( + !previous || + previous.providerSessionOnly || + previous.agentPresence?.ended || + previous.payload.agentType !== event.payload.agentType || + otherProcess(event.agentPresence?.process, previous.agentPresence?.process) + ) { + return event + } + const providerSession = event.providerSession ?? previous.providerSession + const model = event.payload.model ?? previous.payload.model + if (providerSession === event.providerSession && model === event.payload.model) { + return event + } + return { + ...event, + ...(providerSession ? { providerSession } : {}), + payload: model ? { ...event.payload, model } : event.payload + } +} + +/** A pane has one owning agent. Only the owner's hook events write the row; a different agent's are + * guests until the owner is proven gone. Shared by main (local rows) and the relay (remote rows). */ export function transitionHookPresence( incoming: AgentHookEventPayload, - previous: AgentHookEventPayload | undefined -): AgentHookEventPayload | undefined { - const recorded = previous?.agentPresence - const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined - const sender = incoming.agentPresence?.process + previous: AgentHookEventPayload | undefined, + rowUpdatedAt: number | undefined, + now = Date.now() +): HookPresenceTransition { + const { nestedIn: _nestedIn, ...event } = incoming + const owner = currentOwner(previous) + const producer = readHookProducer(incoming) // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved // exit); other agents' SessionEnd hooks are ordinary status updates. const exit = incoming.agentPresence?.ended === true - if (owner) { - if (exit) { - const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) - return fromOwner + if (owner && previous) { + const verdict = classifyAgainstOwner(producer, owner, previous, rowUpdatedAt, now) + if (verdict === 'nested') { + return { kind: 'skip' } + } + if (verdict === 'guest') { + return exit || !owner.process ? { kind: 'skip' } : { kind: 'skip', probe: owner.process } + } + if (verdict === 'owner' && exit) { + return sameProcess(owner.process, producer.process) ? { - ...incoming, - payload: previous?.payload ?? incoming.payload, - agentPresence: { ...owner, ended: true } + kind: 'write', + event: { ...event, payload: previous.payload, agentPresence: { ...owner, ended: true } } } - : undefined + : { kind: 'skip' } + } + if (verdict === 'owner') { + const probe = otherProcess(producer.process, owner.process) ? owner.process : undefined + return { + kind: 'write', + event: { + ...carryOwnerFields(withOwnerAgentType(event, owner), previous), + agentPresence: withOwnerSession(owner, producer) + }, + ...(probe ? { probe } : {}) + } } - // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. - return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } } - if (exit) { - return undefined + const ended = previous?.agentPresence?.ended ? previous.agentPresence : undefined + // Why: a producer nested inside another agent's session never takes an ownerless pane, so a + // restart that brings both back cannot hand the pane to the nested one. + if (exit || sameProcess(ended?.process, producer.process) || producer.nestedIn.length > 0) { + return { kind: 'skip' } } - if ( - recorded?.ended && - recorded.process && - sender && - isSameAgentProcess(recorded.process, sender) - ) { - return undefined + if (!producer.agent) { + return { kind: 'write', event: { ...event, agentPresence: undefined } } } - const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType - if (!agent || agent === 'unknown') { - return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming + return { + kind: 'write', + event: { + ...event, + agentPresence: { + agent: producer.agent, + ...(producer.process ? { process: producer.process } : {}), + ...(producer.session ? { session: producer.session } : {}) + } + } + } +} + +/** Main adopts a relayed row as its relay built it; host restatements (cancel inference, expiry) + * carry no owner, so the relayed one stands. */ +export function adoptRelayedRow( + incoming: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload { + const owner = incoming.agentPresence ?? currentOwner(previous) + const adopted = { ...incoming, agentPresence: owner } + return carryOwnerFields( + owner && !owner.ended ? withOwnerAgentType(adopted, owner) : adopted, + previous + ) +} + +export type TerminalSignalVerdict = + | { kind: 'yield'; probe?: AgentProcessIdentity } + | { kind: 'write'; owner?: AgentProcessPresence } + +/** Terminal signals (OSC, title, process-derived) never claim. One naming another agent than a held + * owner yields: the row stays and the owner is doubted. Otherwise it writes under the owner when it + * names it (or no agent), and ownerless once the owner was released. */ +export function classifyTerminalSignal( + previous: (AgentHookEventPayload & { receivedAt: number }) | undefined, + agentType: string | undefined, + ownerCheckable: boolean, + now = Date.now() +): TerminalSignalVerdict { + const owner = currentOwner(previous) + if (!owner || !previous) { + return { kind: 'write' } + } + if (!agentType || agentType === 'unknown' || agentType === owner.agent) { + return { kind: 'write', owner } + } + if (isOwnerReleased(owner, previous, previous.receivedAt, now, ownerCheckable)) { + return { kind: 'write' } } - return { ...incoming, agentPresence: { agent, ...(sender ? { process: sender } : {}) } } + return ownerCheckable && owner.process + ? { kind: 'yield', probe: owner.process } + : { kind: 'yield' } } diff --git a/src/shared/agent-pane-owner-probes.test.ts b/src/shared/agent-pane-owner-probes.test.ts new file mode 100644 index 000000000000..0028f7bf1b4c --- /dev/null +++ b/src/shared/agent-pane-owner-probes.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' +import { + HELD_GUEST_WINDOW_MS, + OWNER_PROBE_COOLDOWN_MS, + PaneOwnerProbes +} from './agent-pane-owner-probes' + +const OWNER = { pid: 4001, platform: 'linux' as const, startTime: 'boot:1' } +const HOLD = { kind: 'skip' as const, probe: OWNER } + +function setup(checkOwner: () => Promise) { + let now = 1_000 + let owner: AgentProcessIdentity = OWNER + const probes = new PaneOwnerProbes({ ownerOf: () => owner, checkOwner, now: () => now }) + return { + probes, + advance: (ms: number) => (now += ms), + replaceOwner: (next: AgentProcessIdentity) => (owner = next) + } +} + +describe('PaneOwnerProbes', () => { + it('checks one owner once per window, and again after it', () => { + const checkOwner = vi.fn(async (): Promise => 'live') + const { probes, advance } = setup(checkOwner) + for (let index = 0; index < 5; index += 1) { + probes.admit('pane', HOLD, { write: vi.fn(), reapply: vi.fn() }) + } + expect(checkOwner).toHaveBeenCalledOnce() + advance(OWNER_PROBE_COOLDOWN_MS) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: vi.fn() }) + expect(checkOwner).toHaveBeenCalledTimes(2) + }) + + it('replays only the latest held event, whichever signal started the check', async () => { + let release: (verdict: AgentProcessVerdict) => void = () => {} + const { probes } = setup( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const first = vi.fn() + const latest = vi.fn() + probes.probe('pane', OWNER) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: first }) + probes.admit('pane', HOLD, { write: vi.fn(), reapply: latest }) + release('exited') + await vi.waitFor(() => expect(latest).toHaveBeenCalledOnce()) + expect(first).not.toHaveBeenCalled() + }) + + it('drops a held event older than the window', async () => { + let release: (verdict: AgentProcessVerdict) => void = () => {} + const { probes, advance } = setup( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const reapply = vi.fn() + probes.admit('pane', HOLD, { write: vi.fn(), reapply }) + advance(HELD_GUEST_WINDOW_MS + 1) + release('exited') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(reapply).not.toHaveBeenCalled() + }) + + it('never replays a guest held behind an owner that is gone onto a later owner', async () => { + const checkOwner = vi.fn(async (): Promise => 'exited') + const { probes, replaceOwner } = setup(checkOwner) + const reapply = vi.fn() + checkOwner.mockResolvedValueOnce('live') + probes.admit('pane', HOLD, { write: vi.fn(), reapply }) + await vi.waitFor(() => expect(checkOwner).toHaveBeenCalledOnce()) + replaceOwner({ ...OWNER, pid: 4002 }) + await probes.check('pane') + expect(reapply).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/agent-pane-owner-probes.ts b/src/shared/agent-pane-owner-probes.ts new file mode 100644 index 000000000000..40fff6fc0fad --- /dev/null +++ b/src/shared/agent-pane-owner-probes.ts @@ -0,0 +1,99 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import type { HookPresenceTransition } from './agent-hook-presence-transition' +import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' + +/** One probe per owner per window, however many guest events arrive. */ +export const OWNER_PROBE_COOLDOWN_MS = 10_000 +/** How long a held guest event may still take the pane once its owner is found gone. */ +export const HELD_GUEST_WINDOW_MS = 30_000 + +type OwnerProbe = { owner: string; startedAt: number } +type HeldGuest = { owner: string; heldAt: number; reapply: () => void } + +function ownerKey(owner: AgentProcessIdentity): string { + return `${owner.platform}:${owner.pid}:${owner.startTime}` +} + +/** A host's one owner-check surface: applies presence transitions, checks doubted owners without + * making ingest wait, and hands a released pane to the guest held behind that same owner. */ +export class PaneOwnerProbes { + private readonly probes = new Map() + private readonly held = new Map() + + constructor( + private readonly deps: { + /** The pane's current owner process, if the host can check it. */ + ownerOf: (paneKey: string) => AgentProcessIdentity | undefined + /** The host's own check; resolves `exited` only once it released the pane. */ + checkOwner: (paneKey: string) => Promise + now?: () => number + } + ) {} + + admit( + paneKey: string, + transition: HookPresenceTransition, + host: { write: (event: AgentHookEventPayload) => T; reapply: () => void } + ): T | undefined { + if (transition.kind === 'skip') { + if (transition.probe) { + // Why: one slot per pane; the latest guest event is what a released pane should show. + const owner = ownerKey(transition.probe) + this.held.set(paneKey, { owner, heldAt: this.now(), reapply: host.reapply }) + this.probe(paneKey, transition.probe) + } + return undefined + } + const written = host.write(transition.event) + // Why after the write: the host's check is bound to the row it reads, so it must read this one. + if (transition.probe) { + this.probe(paneKey, transition.probe) + } + return written + } + + /** Rate-limited check of an owner another signal cast doubt on. */ + probe(paneKey: string, owner: AgentProcessIdentity): void { + const now = this.now() + this.sweep(now) + const key = ownerKey(owner) + if (this.probes.get(paneKey)?.owner === key) { + return + } + this.probes.set(paneKey, { owner: key, startedAt: now }) + void this.check(paneKey) + } + + /** Checks the pane's owner now; every host caller that can release a pane goes through here. */ + check(paneKey: string): Promise { + const owner = this.deps.ownerOf(paneKey) + return this.deps.checkOwner(paneKey).then((verdict) => { + const held = this.held.get(paneKey) + if (verdict === 'exited' && owner && held?.owner === ownerKey(owner)) { + this.held.delete(paneKey) + // Why: replay re-classifies against the released row, so it only lands if it may claim it. + if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) { + held.reapply() + } + } + return verdict + }) + } + + private sweep(now: number): void { + for (const [paneKey, held] of this.held) { + if (now - held.heldAt > HELD_GUEST_WINDOW_MS) { + this.held.delete(paneKey) + } + } + for (const [paneKey, probe] of this.probes) { + if (now - probe.startedAt >= OWNER_PROBE_COOLDOWN_MS) { + this.probes.delete(paneKey) + } + } + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } +} diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index cbde61981bfd..28e4531bd388 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,3 +1,4 @@ +import { normalizeSessionId } from './agent-session-resume' import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' import type { AgentType } from './agent-status-types' @@ -11,9 +12,18 @@ export type AgentProcessIdentity = { export type AgentProcessPresence = { agent: AgentType process?: AgentProcessIdentity + /** Provider session the owner holds now. */ + session?: string + /** Sessions the owner held before (e.g. across Claude `/clear`), newest first. */ + heldSessions?: string[] ended?: true } +export const MAX_OWNER_HELD_SESSIONS = 4 + +/** An agent session a producer runs inside, read from the env markers its parent exported. */ +export type NestedAgentSession = { agent: AgentType; session: string } + export type AgentProcessVerdict = 'live' | 'unverifiable' | 'exited' export function readAgentProcessIdentity(value: unknown): AgentProcessIdentity | undefined { @@ -58,9 +68,19 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence | return undefined } const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined + const session = 'session' in value ? normalizeSessionId(value.session) : null + const heldSessions = + 'heldSessions' in value && Array.isArray(value.heldSessions) + ? value.heldSessions + .slice(0, MAX_OWNER_HELD_SESSIONS) + .map(normalizeSessionId) + .filter((held): held is string => held !== null) + : [] return { agent: value.agent, ...(process ? { process } : {}), + ...(session ? { session } : {}), + ...(heldSessions.length > 0 ? { heldSessions } : {}), ...('ended' in value && value.ended === true ? { ended: true as const } : {}) } } diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ba1f5574ab1f..2adc8e1d0c31 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -97,7 +97,7 @@ export function hasUnsafeProviderSessionIdChars(value: string): boolean { return false } -function normalizeSessionId(value: unknown): string | null { +export function normalizeSessionId(value: unknown): string | null { if (typeof value !== 'string') { return null }