fix(agent-teams): replace panes only after confirmed stop

This commit is contained in:
Brennan Benson
2026-08-16 19:38:02 -07:00
parent 2ada9c07f9
commit f69e8b5c3e
2 changed files with 27 additions and 28 deletions
@@ -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 () => {
@@ -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(