From f69e8b5c3e56faf313448aff5f41220ee1056386 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:38:02 -0700 Subject: [PATCH] fix(agent-teams): replace panes only after confirmed stop --- .../claude-agent-teams-service.test.ts | 15 +++---- .../claude-agent-teams-tmux-dispatcher.ts | 40 +++++++++---------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/claude-agent-teams-service.test.ts b/src/main/runtime/claude-agent-teams-service.test.ts index cf370e3d687..57f867cb1c2 100644 --- a/src/main/runtime/claude-agent-teams-service.test.ts +++ b/src/main/runtime/claude-agent-teams-service.test.ts @@ -189,7 +189,7 @@ describe('ClaudeAgentTeamsService', () => { expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-2') }) - it('keeps the placeholder handle when the respawn split fails', async () => { + it('removes the pane when replacement split fails after a confirmed placeholder stop', async () => { const { service, teamId, token, leaderPane, api } = createServiceWithLeader() const request = (argv: string[], envPane = leaderPane) => service.handleTmuxCompat({ teamId, token, envPane, argv }, api) @@ -212,10 +212,10 @@ describe('ClaudeAgentTeamsService', () => { request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a']) ).resolves.toMatchObject({ ok: false, exitCode: 1 }) - // the placeholder terminal is left intact and the fake pane id still resolves. - expect(api.closeTerminal).not.toHaveBeenCalled() - await request(['kill-pane', '-t', '%2']) expect(api.closeTerminal).toHaveBeenCalledWith('teammate-1') + await expect( + request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}']) + ).resolves.toMatchObject({ stdout: '%1\n' }) }) it('keeps a pane registered when its process stop is unconfirmed', async () => { @@ -239,7 +239,7 @@ describe('ClaudeAgentTeamsService', () => { ).resolves.toMatchObject({ stdout: '%1\n%2\n' }) }) - it('keeps the replacement addressable when the retired placeholder stop is unconfirmed', async () => { + it('does not launch a replacement when the placeholder stop is unconfirmed', async () => { const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader() const request = (argv: string[], envPane = leaderPane) => service.handleTmuxCompat({ teamId, token, envPane, argv }, api) @@ -256,14 +256,15 @@ describe('ClaudeAgentTeamsService', () => { request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a']) ).resolves.toMatchObject({ ok: false, exitCode: 1 }) expect(api.closeTerminal).toHaveBeenNthCalledWith(1, 'teammate-1') + expect(splitCalls).toHaveLength(1) await expect( request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a']) ).resolves.toMatchObject({ ok: false, exitCode: 1 }) - expect(splitCalls).toHaveLength(2) + expect(splitCalls).toHaveLength(1) await request(['kill-pane', '-t', '%2']) - expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-2') + expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-1') }) it('refuses to respawn the leader pane', async () => { diff --git a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts index 37f43ef9bcc..a9a3b4484a8 100644 --- a/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts +++ b/src/main/runtime/claude-agent-teams-tmux-dispatcher.ts @@ -164,31 +164,25 @@ export class ClaudeAgentTeamsTmuxDispatcher { const origin = (pane.splitFromPane ? team.panes.get(pane.splitFromPane) : undefined) ?? team.panes.get(team.leaderPane)! - // Why: create the replacement before destroying the placeholder so a failed - // split leaves the fake pane id pointing at a still-live terminal; on cleanup - // failure, discard the new split and keep the placeholder registered. const previousHandle = pane.handle - const split = await api.splitTerminal(origin.handle, { - direction: pane.splitDirection ?? 'horizontal', - command, - env: paneEnv(team, pane.fakePaneId), - envToDelete: ['TERM_PROGRAM'], - activate: false - }) + const close = await api.closeTerminal(previousHandle) + if (!close.ptyKilled) { + pane.respawnBlockedReason = describeUnconfirmedAgentStop(close) + throw new Error(pane.respawnBlockedReason) + } try { - const close = await api.closeTerminal(previousHandle) - if (!close.ptyKilled) { - pane.handle = split.handle - pane.respawnBlockedReason = describeUnconfirmedAgentStop(close) - throw new Error(pane.respawnBlockedReason) - } + const split = await api.splitTerminal(origin.handle, { + direction: pane.splitDirection ?? 'horizontal', + command, + env: paneEnv(team, pane.fakePaneId), + envToDelete: ['TERM_PROGRAM'], + activate: false + }) + pane.handle = split.handle } catch (error) { - if (pane.handle === previousHandle) { - await api.closeTerminal(split.handle).catch(() => {}) - } + this.removePane(team, pane) throw error } - pane.handle = split.handle return '' } @@ -281,13 +275,17 @@ export class ClaudeAgentTeamsTmuxDispatcher { if (!close.ptyKilled) { throw new Error(describeUnconfirmedAgentStop(close)) } + this.removePane(team, pane) + return '' + } + + private removePane(team: AgentTeam, pane: TeamPane): void { team.panes.delete(pane.fakePaneId) team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId) if (team.mainVertical?.lastColumnPane === pane.fakePaneId) { team.mainVertical.lastColumnPane = [...team.paneOrder].toReversed().find((id) => id !== team.leaderPane) ?? null } - return '' } private async lastPane(