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
+8 -1
View File
@@ -19645,10 +19645,17 @@ body.tile-grid-resizing--row * {
color: var(--text-muted); color: var(--text-muted);
} }
.tile-attach[hidden] { .tile-attach[hidden],
.tile-attach [hidden] {
display: none; display: none;
} }
.tile-attach-hint {
max-width: 80%;
font-size: 12px;
text-align: center;
}
.tile-attach-btn { .tile-attach-btn {
padding: 4px 14px; padding: 4px 14px;
font: inherit; font: inherit;
+30 -13
View File
@@ -916,21 +916,27 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* What the tile's body should say instead of a terminal, or '' for none: a * What the tile's body should say instead of a terminal, or null for none: a
* session with no PTY attached (pid null), an agent that exited in a live * session with no PTY attached (pid null) or a socket the server closed
* pane (paneExit), or a socket the server closed because the session exited * because the session exited (4009), both of which Attach can start again;
* (4009). Attach was just pressed: nothing, while the server catches up. * or an agent that exited in a live pane (paneExit), which it cannot: the
* attach and shell routes refuse while the pane's tmux client still runs
* ("already has a running process"), and the single view has no restart for
* it either, so the tile says so and points at Close session instead. Attach
* was just pressed: nothing, while the server catches up.
*
* @returns {{text: string, attachable: boolean}|null}
*/ */
_tileAttachReason(sessionId, tile) { _tileAttachReason(sessionId, tile) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return ''; if (!session) return null;
const pending = this._tileAttachPending?.get(sessionId); const pending = this._tileAttachPending?.get(sessionId);
if (pending && Date.now() - pending < 15000) return ''; if (pending && Date.now() - pending < 15000) return null;
const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : ''; const exited = typeof paneExitLabel === 'function' ? paneExitLabel(session.paneExit) : '';
if (exited) return `The agent ${exited}`; if (exited) return { text: `The agent ${exited}`, attachable: false };
if (session.pid === null) return 'Not attached'; if (session.pid === null) return { text: 'Not attached', attachable: true };
if (tile?._stoppedCode === 4009) return 'The session ended'; if (tile?._stoppedCode === 4009) return { text: 'The session ended', attachable: true };
return ''; return null;
}, },
/** /**
@@ -961,15 +967,22 @@ Object.assign(CodemanApp.prototype, {
e.stopPropagation(); e.stopPropagation();
void this.attachTileSession(sessionId); void this.attachTileSession(sessionId);
}); });
overlay.append(text, btn); const hint = document.createElement('span');
hint.className = 'tile-attach-hint';
hint.textContent = 'It cannot be restarted in place: close it from \u22EF (Close session).';
overlay.append(text, btn, hint);
entry.body.appendChild(overlay); entry.body.appendChild(overlay);
entry.overlay = overlay; entry.overlay = overlay;
entry.overlayText = text; entry.overlayText = text;
entry.overlayBtn = btn; entry.overlayBtn = btn;
entry.overlayHint = hint;
} }
entry.overlay.hidden = false; entry.overlay.hidden = false;
const text = busy ? 'Attaching\u2026' : reason; const text = busy ? 'Attaching\u2026' : reason.text;
if (entry.overlayText.textContent !== text) entry.overlayText.textContent = text; if (entry.overlayText.textContent !== text) entry.overlayText.textContent = text;
const attachable = busy || reason.attachable;
entry.overlayBtn.hidden = !attachable;
entry.overlayHint.hidden = attachable;
entry.overlayBtn.disabled = busy; entry.overlayBtn.disabled = busy;
}, },
@@ -984,6 +997,8 @@ Object.assign(CodemanApp.prototype, {
async attachTileSession(sessionId) { async attachTileSession(sessionId) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return false; if (!session) return false;
// An agent that exited in a live pane cannot be started again in place.
if (this._tileAttachReason(sessionId, this._tileFor(sessionId))?.attachable === false) return false;
this._tileAttachInFlight ||= new Set(); this._tileAttachInFlight ||= new Set();
if (this._tileAttachInFlight.has(sessionId)) return false; if (this._tileAttachInFlight.has(sessionId)) return false;
let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`; let url = `/api/sessions/${sessionId}/${session.mode === 'shell' ? 'shell' : 'interactive'}`;
@@ -1003,7 +1018,9 @@ Object.assign(CodemanApp.prototype, {
let ok = false; let ok = false;
try { try {
const res = await fetch(url, init); const res = await fetch(url, init);
ok = !!res?.ok; // The routes report a refusal in the envelope of a 200.
const body = await res?.json?.().catch(() => null);
ok = !!res?.ok && body?.success !== false;
} catch { } catch {
ok = false; ok = false;
} finally { } finally {
+21 -2
View File
@@ -63,10 +63,20 @@ describe('when the overlay shows', () => {
expect(visible('s-a')).toBe(false); expect(visible('s-a')).toBe(false);
}); });
it('an agent that exited in a live pane', () => { it('an agent that exited in a live pane: the reason, and no Attach (the server cannot restart it in place)', async () => {
gridWith((app) => (app.sessions.get('s-c').paneExit = { status: 2 })); const app = gridWith((a) => (a.sessions.get('s-c').paneExit = { status: 2 }));
expect(visible('s-c')).toBe(true); expect(visible('s-c')).toBe(true);
expect(textOf('s-c')).toBe('The agent exited (2)'); 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', () => { it('a socket closed because the session exited (4009) keeps the tile and shows it', () => {
@@ -133,6 +143,15 @@ describe('Attach', () => {
await first; 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 () => { it('a failed attach keeps the overlay and says so', async () => {
const app = gridWith((a) => (a.sessions.get('s-b').pid = null)); const app = gridWith((a) => (a.sessions.get('s-b').pid = null));
fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) })); fetchSpy.mockImplementation(async () => ({ ok: false, json: async () => ({}) }));