fix(tiles): Attach reads the response envelope; an exited agent gets no Attach

Found live: the attach and shell routes report a refusal in the envelope of
a 200 ({success: false}), and Attach read only res.ok, so a refused attach
remounted the tile as if it had worked. It now reads the envelope.

The refusal in question: an agent that exited in a live pane (paneExit, e.g.
a shell ended with `exit 3`) still has the pane's tmux client running, so
both routes refuse to start anything ("Session already has a running
process"), and the single view has no restart for it either. Its tile now
shows the exit with a pointer to Close session instead of an Attach button
that cannot work. A session with no PTY attached, or one whose socket closed
because it exited (4009), still gets Attach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 18:09:18 +02:00
parent dbff114dda
commit d331db1141
3 changed files with 59 additions and 16 deletions
+21 -2
View File
@@ -63,10 +63,20 @@ describe('when the overlay shows', () => {
expect(visible('s-a')).toBe(false);
});
it('an agent that exited in a live pane', () => {
gridWith((app) => (app.sessions.get('s-c').paneExit = { status: 2 }));
it('an agent that exited in a live pane: the reason, and no Attach (the server cannot restart it in place)', async () => {
const app = gridWith((a) => (a.sessions.get('s-c').paneExit = { status: 2 }));
expect(visible('s-c')).toBe(true);
expect(textOf('s-c')).toBe('The agent exited (2)');
expect(attachButton('s-c').hidden).toBe(true);
expect(overlayOf('s-c')!.children[2].hidden).toBe(false);
expect(await app.attachTileSession('s-c')).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('a session with no PTY gets the button, not the close hint', () => {
gridWith((app) => (app.sessions.get('s-b').pid = null));
expect(attachButton('s-b').hidden).toBe(false);
expect(overlayOf('s-b')!.children[2].hidden).toBe(true);
});
it('a socket closed because the session exited (4009) keeps the tile and shows it', () => {
@@ -133,6 +143,15 @@ describe('Attach', () => {
await first;
});
it('a refusal in the envelope of a 200 is a failure, not a success', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
fetchSpy.mockImplementation(async () => ({ ok: true, json: async () => ({ success: false, error: 'busy' }) }));
expect(await app.attachTileSession('s-b')).toBe(false);
expect(app.showToast).toHaveBeenCalledWith('Could not attach the session', 'error');
expect(tilesFor('s-b')).toHaveLength(1);
expect(visible('s-b')).toBe(true);
});
it('a failed attach keeps the overlay and says so', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) }));