fix: let go of an adopted tab whose tmux session ended (release review)

When a foreign session ended, remain-on-exit kept Codeman's own tmux client
on the wrapper's dead pane, so the tab kept its pid and busy, input went
nowhere, both attach routes refused it, and a new session of that name only
led back to the dead tab. paneExit stays out of it by design.

The reconnect watcher's tick now detaches a client from a dead wrapper. That
is an ordinary PTY exit with status 0, so the pid goes null and selecting the
tab runs the attach again: it joins the target if it is back and otherwise
prints the existing guard text. Checked on an isolated instance and in a
headless browser: boot recovery, target gone, target back, view detached, tab
closed, and no restart loop while the tab sits unselected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 07:44:22 +01:00
1 parent 82593a4dd6
commit e7c68f1340
3 files changed
+125 -1

No files matched your search

+69
View File
@@ -18,6 +18,7 @@ import {
parsePaneRows,
derivePaneExits,
hasObservablePaneSession,
endedAdoptedAttaches,
type PaneRow,
resolveActivePaneTarget,
} from '../src/tmux-manager.js';
@@ -1721,3 +1722,71 @@ describe('hasObservablePaneSession', () => {
// the rule is enforced; that pairing is pinned in session-pane-exit.test.ts,
// where a real Session can answer for itself.
});
describe('an adopted wrapper whose pane died', () => {
const adopted = new Set(['codeman-aaaa1111']);
class WatchedManager extends TmuxManager {
rows = '';
detached: string[] = [];
protected override async readAttachRows(): Promise<string> {
return this.rows;
}
protected override async detachClients(muxName: string): Promise<void> {
this.detached.push(muxName);
}
adopt(sessionId: string, adopt?: MuxSession['adopt']): void {
const session = {
sessionId,
muxName: `codeman-${sessionId}`,
pid: 1,
createdAt: 0,
workingDir: '/tmp',
mode: 'shell',
attached: false,
adopt,
};
(this as unknown as { sessions: Map<string, MuxSession> }).sessions.set(sessionId, session as MuxSession);
}
}
const adoption = {
location: 'local',
socketPath: '/tmp/s',
targetSession: 'work',
viewSession: 'codeman-view-aaaa1111',
} as MuxSession['adopt'];
it('reads as ended only with a dead pane and a client still on it', () => {
expect(endedAdoptedAttaches('1 1 codeman-aaaa1111\n', adopted)).toEqual(['codeman-aaaa1111']);
expect(endedAdoptedAttaches('1 2 codeman-aaaa1111', adopted)).toEqual(['codeman-aaaa1111']);
// Nothing to detach: a second try would only print `no current client` every tick.
expect(endedAdoptedAttaches('1 0 codeman-aaaa1111', adopted)).toEqual([]);
expect(endedAdoptedAttaches('0 1 codeman-aaaa1111', adopted)).toEqual([]);
expect(endedAdoptedAttaches('', adopted)).toEqual([]);
});
it("leaves every other session's dead pane alone, since that one is an agent's exit", () => {
expect(endedAdoptedAttaches('1 1 codeman-bbbb2222', adopted)).toEqual([]);
});
it('detaches the client from a dead wrapper on the tick', async () => {
const manager = new WatchedManager();
manager.adopt('aaaa1111', adoption);
manager.adopt('bbbb2222');
manager.rows = '1 1 codeman-aaaa1111\n1 1 codeman-bbbb2222\n';
await manager.detachEndedAdoptedAttaches();
expect(manager.detached).toEqual(['codeman-aaaa1111']);
});
it('does not read tmux while nothing is adopted', async () => {
const manager = new WatchedManager();
manager.adopt('bbbb2222');
const read = vi.spyOn(manager as unknown as { readAttachRows: () => Promise<string> }, 'readAttachRows');
await manager.detachEndedAdoptedAttaches();
expect(read).not.toHaveBeenCalled();
});
});