From ced9fd53215d55ac7ec55ee3d3e21714ee3e3ccf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:14:34 -0700 Subject: [PATCH] fix(runtime): preserve pane reservation creator identity --- src/main/ipc/pty.test.ts | 310 ++++++++++++++++++ .../claude-agent-teams-service.test.ts | 22 ++ .../runtime/claude-agent-teams-service.ts | 9 + src/main/runtime/orca-runtime.ts | 69 +++- 4 files changed, 403 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f34d49acc43..4f91df4c8e0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -631,6 +631,132 @@ describe('registerPtyHandlers', () => { return spawn } + function createFullRuntimePaneRaceHarness() { + type ProviderSpawnOptions = { + cwd?: string + env?: Record + sessionId?: string + } + const folder = { + id: 'full-runtime-pane-race-folder', + projectGroupId: 'full-runtime-pane-race-group', + name: 'Full runtime pane race', + folderPath: process.cwd(), + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + const group = { + id: folder.projectGroupId, + name: folder.projectGroupId, + parentPath: folder.folderPath, + connectionId: null, + parentGroupId: null, + createdFrom: 'manual' as const, + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const pending: { + options: ProviderSpawnOptions + resolve: (result: { id: string }) => void + }[] = [] + const providerSpawn = vi.fn( + (options: ProviderSpawnOptions) => + new Promise<{ id: string }>((resolve) => pending.push({ options, resolve })) + ) + const shutdown = vi.fn() + installDaemonTestProvider({ spawn: providerSpawn, shutdown }) + const store = { + getFolderWorkspace: vi.fn(() => folder), + getFolderWorkspaces: vi.fn(() => [folder]), + getProjectGroups: vi.fn(() => [group]), + getRepos: vi.fn(() => []), + getSettings: vi.fn(() => ({ claudeAgentTeamsMode: 'native-panes-shim' as const })), + persistPtyBinding: vi.fn(), + removePtyBindingIfMatches: vi.fn() + } + const runtime = new OrcaRuntimeService(store as never) + vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_renderer_creator') + registerPtyHandlers( + mainWindow as never, + runtime, + undefined, + undefined, + undefined, + store as never + ) + const folderKey = `folder:${folder.id}` + const tabId = 'tab-full-runtime-pane-race' + const leafId = '16161616-1616-4616-8616-161616161616' + const paneKey = makePaneKey(tabId, leafId) + const launchConfig = (owner: string) => ({ + agentCommand: 'claude --teammate-mode auto', + agentArgs: `--${owner}`, + agentEnv: { OWNER: owner } + }) + const spawnRenderer = (sessionId?: string) => + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: folder.folderPath, + worktreeId: folderKey, + tabId, + leafId, + ...(sessionId ? { sessionId } : {}), + env: { ORCA_PANE_KEY: paneKey }, + command: 'claude --teammate-mode auto', + launchAgent: 'claude', + launchConfig: launchConfig('creator') + }) as Promise<{ id: string; spawnDisposition: string }> + const spawnRuntime = (opts: { + sessionId?: string + signal?: AbortSignal + acceptWorkspaceInventory?: () => boolean + preAllocatedHandle?: string + }) => + runtime.createTerminal(`id:${folderKey}`, { + command: 'claude --teammate-mode auto', + launchAgent: 'claude', + launchConfig: launchConfig('waiter'), + title: 'waiter title', + tabId, + leafId, + persistHostSessionBinding: true, + spawnReservationFreshness: + runtime.getTerminalSpawnReservationFreshness(folderKey, null) ?? undefined, + ...opts + }) + const teams = (runtime as unknown as { claudeAgentTeams: ClaudeAgentTeamsService }) + .claudeAgentTeams + return { + folder, + group, + folderKey, + tabId, + leafId, + paneKey, + pending, + providerSpawn, + shutdown, + store, + runtime, + teams, + launchConfig, + spawnRenderer, + spawnRuntime + } + } + function installObservableDaemonTestProvider() { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' @@ -8630,6 +8756,190 @@ describe('registerPtyHandlers', () => { clearProviderPtyState(creatorSessionId) }) + it('adopts a renderer creator without rebinding its full runtime identity', async () => { + const harness = createFullRuntimePaneRaceHarness() + const sessionId = 'caller-owned-full-runtime-session' + const rendererSpawn = harness.spawnRenderer(sessionId) + await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce()) + const creatorOptions = harness.pending[0]!.options + expect(creatorOptions.env?.ORCA_TERMINAL_HANDLE).toBe('term_renderer_creator') + + const runtimeSpawn = harness.spawnRuntime({ + sessionId, + preAllocatedHandle: 'term_runtime_waiter', + acceptWorkspaceInventory: () => true + }) + await vi.waitFor(() => expect(harness.teams.getActiveTeamCount()).toBe(2)) + + harness.runtime.registerPreAllocatedHandleForPty(sessionId, 'term_renderer_creator') + harness.runtime.registerPty(sessionId, harness.folderKey, null, { + tabId: harness.tabId, + leafId: harness.leafId + }) + const creatorLaunchConfig = harness.launchConfig('stored-creator') + const creatorPty = ( + harness.runtime as unknown as { + ptysById: Map< + string, + { + runtimeSessionOwned: boolean + title: string | null + titleUpdatedAt: number | null + launchConfig: ReturnType | null + launchToken: string | null + launchAgent: TuiAgent | null + } + > + } + ).ptysById.get(sessionId)! + Object.assign(creatorPty, { + runtimeSessionOwned: false, + title: 'creator title', + titleUpdatedAt: Date.now(), + launchConfig: creatorLaunchConfig, + launchToken: 'creator-launch-token', + launchAgent: null + }) + + harness.pending[0]!.resolve({ id: sessionId }) + await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([ + expect.objectContaining({ id: sessionId, spawnDisposition: 'created' }), + expect.objectContaining({ + handle: 'term_renderer_creator', + ptyId: sessionId, + tabId: harness.tabId, + paneKey: harness.paneKey, + worktreeId: harness.folderKey, + title: 'creator title', + surface: 'background' + }) + ]) + + expect(harness.providerSpawn).toHaveBeenCalledOnce() + expect(harness.teams.getActiveTeamCount()).toBe(1) + expect(creatorPty).toMatchObject({ + runtimeSessionOwned: false, + title: 'creator title', + launchConfig: creatorLaunchConfig, + launchToken: 'creator-launch-token', + launchAgent: null + }) + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(sessionId) + expect(piClearPtyMock).not.toHaveBeenCalledWith(sessionId) + clearProviderPtyState(sessionId) + }) + + it.each([ + { fence: 'inventory', expected: 'tab_not_found' }, + { fence: 'abort', expected: 'client_disconnected' } + ] as const)('cleans a provisional runtime team before a $fence spawn exit', async (testCase) => { + const harness = createFullRuntimePaneRaceHarness() + const abort = new AbortController() + if (testCase.fence === 'abort') { + abort.abort() + } + const removeTeam = vi.spyOn(harness.teams, 'removeTeamForLaunchEnv') + + await expect( + harness.spawnRuntime({ + sessionId: `caller-owned-pre-spawn-${testCase.fence}`, + preAllocatedHandle: `term_pre_spawn_${testCase.fence}`, + signal: abort.signal, + acceptWorkspaceInventory: () => testCase.fence !== 'inventory' + }) + ).rejects.toThrow(testCase.expected) + + expect(harness.providerSpawn).not.toHaveBeenCalled() + expect(removeTeam).toHaveBeenCalledOnce() + expect(harness.teams.getActiveTeamCount()).toBe(0) + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith( + `caller-owned-pre-spawn-${testCase.fence}` + ) + }) + + it.each([ + { fence: 'inventory', expected: 'tab_not_found' }, + { fence: 'abort', expected: 'client_disconnected' } + ] as const)( + 'abandons only the runtime waiter when a $fence changes after reservation', + async (testCase) => { + const harness = createFullRuntimePaneRaceHarness() + const sessionId = `caller-owned-post-spawn-${testCase.fence}` + const rendererSpawn = harness.spawnRenderer(sessionId) + await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce()) + let inventoryAccepted = true + const abort = new AbortController() + const runtimeSpawn = harness.spawnRuntime({ + sessionId, + preAllocatedHandle: `term_post_spawn_${testCase.fence}`, + signal: abort.signal, + acceptWorkspaceInventory: () => inventoryAccepted + }) + await vi.waitFor(() => expect(harness.teams.getActiveTeamCount()).toBe(2)) + + if (testCase.fence === 'inventory') { + inventoryAccepted = false + } else { + abort.abort() + } + harness.pending[0]!.resolve({ id: sessionId }) + + await expect(rendererSpawn).resolves.toMatchObject({ + id: sessionId, + spawnDisposition: 'created' + }) + await expect(runtimeSpawn).rejects.toThrow(testCase.expected) + expect(harness.providerSpawn).toHaveBeenCalledOnce() + expect(harness.shutdown).not.toHaveBeenCalled() + expect(harness.teams.getActiveTeamCount()).toBe(1) + expect( + (harness.runtime as unknown as { handleByPtyId: Map }).handleByPtyId.get( + sessionId + ) + ).toBe('term_renderer_creator') + expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(sessionId) + expect(piClearPtyMock).not.toHaveBeenCalledWith(sessionId) + clearProviderPtyState(sessionId) + } + ) + + it('isolates a changed folder route from the renderer creator reservation', async () => { + const harness = createFullRuntimePaneRaceHarness() + const oldPath = harness.folder.folderPath + const rendererSpawn = harness.spawnRenderer() + await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce()) + + harness.folder.folderPath = join(process.cwd(), 'src') + harness.group.parentPath = harness.folder.folderPath + const runtimeSpawn = harness.spawnRuntime({ + preAllocatedHandle: 'term_current_folder_route', + acceptWorkspaceInventory: () => harness.folder.folderPath.endsWith('src') + }) + expect(harness.providerSpawn).toHaveBeenCalledOnce() + const oldSessionId = harness.pending[0]!.options.sessionId! + harness.pending[0]!.resolve({ id: oldSessionId }) + await expect(rendererSpawn).resolves.toMatchObject({ id: oldSessionId }) + await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledTimes(2)) + expect(harness.pending.map(({ options }) => options.cwd)).toEqual([ + oldPath, + harness.folder.folderPath + ]) + + const currentSessionId = harness.pending[1]!.options.sessionId! + expect(currentSessionId).not.toBe(oldSessionId) + harness.pending[1]!.resolve({ id: currentSessionId }) + + await expect(runtimeSpawn).resolves.toMatchObject({ + handle: 'term_current_folder_route', + ptyId: currentSessionId, + worktreeId: harness.folderKey + }) + expect(harness.providerSpawn).toHaveBeenCalledTimes(2) + expect(harness.shutdown).not.toHaveBeenCalled() + clearProviderPtyState(oldSessionId) + clearProviderPtyState(currentSessionId) + }) + it.each(['runtime', 'renderer'] as const)( 'preserves caller-owned session state for a %s pane waiter', async (waiterKind) => { diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index f02d470fc8a..f30437aa583 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -73,6 +73,28 @@ function createServiceWithLeader(): { } describe('ClaudeAgentTeamsService', () => { + it('removes only the team named by its launch identity', () => { + const service = new ClaudeAgentTeamsService() + const first = service.createLaunchEnv({ + leaderHandle: 'shared-handle', + baseEnv: {}, + shimDir: '/tmp/orca-shim', + shimBin: 'orca' + }) + const second = service.createLaunchEnv({ + leaderHandle: 'shared-handle', + baseEnv: {}, + shimDir: '/tmp/orca-shim', + shimBin: 'orca' + }) + + service.removeTeamForLaunchEnv(first.env) + + expect(service.getActiveTeamCount()).toBe(1) + service.removeTeamForLaunchEnv(second.env) + expect(service.getActiveTeamCount()).toBe(0) + }) + it('supports Claude core tmux teammate sequence with native splits', async () => { const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() const request = (argv: string[]) => diff --git a/src/main/runtime/claude-agent-teams-service.ts b/src/main/runtime/claude-agent-teams-service.ts index 1da810e4a33..1641292c7d8 100644 --- a/src/main/runtime/claude-agent-teams-service.ts +++ b/src/main/runtime/claude-agent-teams-service.ts @@ -81,6 +81,15 @@ export class ClaudeAgentTeamsService { } } + removeTeamForLaunchEnv(env: Readonly>): void { + const teamId = env.ORCA_AGENT_TEAMS_TEAM_ID + const token = env.ORCA_AGENT_TEAMS_TOKEN + const team = teamId ? this.teams.get(teamId) : undefined + if (team && team.token === token) { + this.teams.delete(teamId) + } + } + getActiveTeamCount(): number { return this.teams.size } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 45c3013e6ad..bc7e5b6c618 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25429,13 +25429,26 @@ export class OrcaRuntimeService { ) const terminalColorQueryReplies = launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() - if (launchOpts.signal?.aborted) { - throw new Error('client_disconnected') + let ownsProvisionalAgentTeam = agentTeamsPlan !== null + const discardProvisionalAgentTeam = (): void => { + if (!ownsProvisionalAgentTeam || !agentTeamsPlan) { + return + } + ownsProvisionalAgentTeam = false + this.claudeAgentTeams.removeTeamForLaunchEnv(agentTeamsPlan.env) } - if (launchOpts.acceptWorkspaceInventory?.() === false) { - throw new Error('tab_not_found') + const getPreAdoptionRejection = (): string | null => { + if (launchOpts.signal?.aborted) { + return 'client_disconnected' + } + return launchOpts.acceptWorkspaceInventory?.() === false ? 'tab_not_found' : null } - const result = await this.ptyController.spawn({ + const preSpawnRejection = getPreAdoptionRejection() + if (preSpawnRejection) { + discardProvisionalAgentTeam() + throw new Error(preSpawnRejection) + } + const spawnPromise = this.ptyController.spawn({ cols: 120, rows: 40, cwd, @@ -25492,7 +25505,16 @@ export class OrcaRuntimeService { ? { persistHostSessionBinding: true } : {}) }) - if (launchOpts.acceptWorkspaceInventory?.() === false) { + let result: Awaited + try { + result = await spawnPromise + } catch (error) { + discardProvisionalAgentTeam() + throw error + } + const postSpawnRejection = getPreAdoptionRejection() + if (postSpawnRejection) { + discardProvisionalAgentTeam() const shouldKill = result.spawnRetirementToken ? this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) === true @@ -25503,7 +25525,38 @@ export class OrcaRuntimeService { if (result.spawnDisposition === 'created') { this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) } - throw new Error('tab_not_found') + throw new Error(postSpawnRejection) + } + if (result.spawnDisposition === 'awaited') { + discardProvisionalAgentTeam() + const creatorPty = this.ptysById.get(result.id) + if (!creatorPty) { + if ( + result.spawnRetirementToken && + this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) === + true + ) { + this.ptyController.kill(result.id) + } + throw new Error('terminal_not_found') + } + if (result.spawnRetirementToken) { + this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken) + } + reportPtySpawnCommitted() + return { + handle: this.issuePtyHandle(creatorPty), + ...(creatorPty.tabId ? { tabId: creatorPty.tabId } : {}), + paneKey: creatorPty.paneKey, + ptyId: creatorPty.ptyId, + worktreeId: creatorPty.worktreeId, + title: getLatestPtyTitle(creatorPty), + ...this.getPtyExecutionHostMetadata(creatorPty.ptyId), + surface: 'background', + ...(result.agentSessionEnsure + ? { agentSessionDisposition: result.agentSessionEnsure.disposition } + : {}) + } } if (result.spawnRetirementToken) { this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken) @@ -25519,12 +25572,14 @@ export class OrcaRuntimeService { try { this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) } catch (error) { + discardProvisionalAgentTeam() if (error instanceof Error && error.message === 'agent_session_exited_during_start') { this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) } throw error } this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + ownsProvisionalAgentTeam = false if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) }