From 7b6e52eafd2a80d62d299d2f7bac72dac2260b61 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:38:15 +0200 Subject: [PATCH 1/3] (sidebar): end a dropped session's running state when main drops it The pty-gone purge ran only when the running-set signature changed, and the status bar count was only redrawn by loadProjects(). A busy signal landing after a poll removed the id left the row busy, and the bar kept counting it. process-exited now removes the id from the running set, drops the activity state through dropLocalPtySession and refreshes the indicators at once; a change of the running set redraws the status bar. Closes #375 --- .ai/contexts/session-state.md | 26 +++++ CHANGELOG.md | 3 + eslint.config.js | 1 + public/app.js | 9 +- public/session-activity.js | 6 ++ test/dropped-session-state.test.js | 161 +++++++++++++++++++++++++++++ test/process-exit-status.test.js | 2 + test/running-indicators.test.js | 4 +- test/session-activity.test.js | 4 +- 9 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 test/dropped-session-state.test.js diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 2b7594cc..1ca50bd7 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -509,6 +509,32 @@ response-ready (unchanged)" pin both branches of the decision — the icon slot's `session-icon--waiting`/`session-icon--response-ready` class and title, side by side, so a future reader finds the split intentional. +#### A session main drops (issue #375) + +Main dropping a local-pty session (`process-exited`, then `activeSessions.delete`) +is the one moment the renderer's running state for it has to end — in the +sidebar row, the status bar and the activity state. The `pty-gone` purge in +`updateRunningIndicators()` only runs when the running-set *signature* changes, +so it could not be the only owner: a `cli-busy-state` or notification that lands +after a poll already removed the id (a CLI stuck in an API retry loop keeps +rewriting its title while it is killed) re-armed `cli-busy` on a row the +signature gate then never revisited, and the status bar's `N running` was only +redrawn by `loadProjects()`. + +- `onProcessExited` (`app.js`) removes the id from `activePtyIds`, calls + `dropLocalPtySession(id, 'process-exited')` for a non-remote row, and runs + `updateRunningIndicators()` at once instead of waiting for the next poll. +- `dropLocalPtySession` (`session-activity.js`) is the single drop: live + subagents first (`clearActiveSubagentsFor`), then `purgeActivityFor`. The + `pty-gone` scan uses the same helper. +- `updateRunningIndicators()` calls `renderDefaultStatus()` whenever the set + changed, so the status bar count follows `activePtyIds`. + +Remote rows stay with the remote adapter (see "Row ownership"). Not covered: a +busy signal that arrives after `process-exited` — main sends none, the PTY is +gone. The pre-fix sequence is reasoned from the code, not reproduced live; +`test/dropped-session-state.test.js` pins each step. + ### The local-transcript adapter (step 4) `public/local-transcript-adapter.js` keeps one persistent diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..8dfeff98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### Fixed +- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375) + ## v0.0.86 — 2026-10-01 ### New diff --git a/eslint.config.js b/eslint.config.js index 6a2a948c..e6a61df5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -153,6 +153,7 @@ const rendererCrossFileGlobals = { currentActivitySeq: 'readonly', forgetActivitySeq: 'readonly', purgeActivityFor: 'readonly', + dropLocalPtySession: 'readonly', pruneRemoteActivityTimers: 'readonly', // public/session-activity.js's persisted per-session state — see .ai/contexts/session-state.md ("The local-pty adapter") localPtyState: 'readonly', diff --git a/public/app.js b/public/app.js index 7f656e99..bec4fcd5 100644 --- a/public/app.js +++ b/public/app.js @@ -509,6 +509,10 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { return; } noteSessionExit(sessionId, exitCode, signal, stopped); + // see .ai/contexts/session-state.md ("A session main drops") + activePtyIds.delete(sessionId); + if (!session?.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); + updateRunningIndicators(); const exit = lastSessionExit(sessionId); if (entry) { entry.closed = true; @@ -956,9 +960,7 @@ function updateRunningIndicators() { // A stopped PTY can never emit subagent-completed (stop-session kills // the process; detectSubagentTransitions skips exited sessions), so // drop the live-subagent state now instead of waiting for the TTL. - clearActiveSubagentsFor(id); - // Runs after clearActiveSubagentsFor — see .ai/contexts/session-state.md ("The local-pty adapter") - purgeActivityFor(id, 'pty-gone'); + dropLocalPtySession(id, 'pty-gone'); } if (item.dataset.remoteAlias) setRemoteAttached(id, running); // local-pty takes over a row the user just opened — see .ai/contexts/session-state.md @@ -976,6 +978,7 @@ function updateRunningIndicators() { const dot = group.querySelector('.slug-group-dot'); if (dot) dot.classList.toggle('running', hasRunning); }); + renderDefaultStatus(); } // Update grid card dots and status text — always run because sessionBusyState diff --git a/public/session-activity.js b/public/session-activity.js index 7bfd004a..72eed2c1 100644 --- a/public/session-activity.js +++ b/public/session-activity.js @@ -97,6 +97,12 @@ function purgeActivityFor(sessionId, via) { applyStateClasses(sessionId, createSessionState('local-pty').snapshot()); } +// see .ai/contexts/session-state.md ("A session main drops") +function dropLocalPtySession(sessionId, via) { + if (typeof clearActiveSubagentsFor === 'function') clearActiveSubagentsFor(sessionId); + purgeActivityFor(sessionId, via); +} + // Central activity dispatcher. `via` is trace-only — see docs/activity-trace.md. // opts.armReady=false: going idle must not arm response-ready — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner") function setActivity(sessionId, active, via, opts) { diff --git a/test/dropped-session-state.test.js b/test/dropped-session-state.test.js new file mode 100644 index 00000000..2c7c7125 --- /dev/null +++ b/test/dropped-session-state.test.js @@ -0,0 +1,161 @@ +// A session main no longer holds must stop counting as running — in the +// sidebar row, the status bar and the activity state. see .ai/contexts/session-state.md ("A session main drops") +// +// app.js cannot be evaluated whole in jsdom, so the shipped onProcessExited +// handler and updateRunningIndicators are cut out of its source and run +// against stubs (same technique as process-exit-status.test.js). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { setupSidebarDom } = require('./dom-setup'); + +const ROOT = path.join(__dirname, '..'); +const APP_SRC = fs.readFileSync(path.join(ROOT, 'public', 'app.js'), 'utf8'); + +function sliceBlock(marker, tail = '') { + const start = APP_SRC.indexOf(marker); + assert.notEqual(start, -1, `app.js must contain ${marker}`); + const lineEndingBrace = /\{\r?\n/g; + lineEndingBrace.lastIndex = start; + const open = lineEndingBrace.exec(APP_SRC); + let depth = 0; + for (let i = open.index; i < APP_SRC.length; i++) { + if (APP_SRC[i] === '{') depth++; + else if (APP_SRC[i] === '}' && --depth === 0) return APP_SRC.slice(start, i + 1) + tail; + } + throw new Error(`unbalanced block after ${marker}`); +} + +const PRELUDE = ` + let activePtyIds = new Set(); + let _lastPtySignature = ''; + const calls = { dropped: [], status: 0, indicators: 0 }; + const openSessions = new Map(); + const sessionMap = new Map(); + const pendingSessions = new Map(); + const cachedProjects = []; + const cachedAllProjects = []; + const gridCards = new Map(); + const sessionBusyState = new Map(); + let gridViewActive = false; + let activeSessionId = null; + const gridViewerCount = document.createElement('span'); + const terminalHeader = document.createElement('div'); + const placeholder = document.createElement('div'); + function isPanelTerminalSession() { return false; } + function notePanelTerminalExit() {} + function noteSessionExit() {} + function lastSessionExit() { return { exitCode: 0, signal: null, stopped: true }; } + function exitBannerColour() { return ''; } + function exitBannerPhrase() { return ''; } + function destroySession() {} + function setActiveSession() {} + function refreshSidebar() {} + function schedulePersistWorkingSet() {} + function pollActiveSessions() {} + function setHasBusyAgents() {} + function setRemoteAttached() {} + function localTranscriptPtyTakeover() {} + function paintSessionIcon() {} + function isSessionAlive() { return false; } + function dropLocalPtySession(id, via) { calls.dropped.push([id, via]); } + function renderDefaultStatus() { calls.status++; } +`; + +function setup() { + const dom = new JSDOM('', { runScripts: 'outside-only' }); + const { window } = dom; + let exitHandler = null; + window.api = { onProcessExited: (cb) => { exitHandler = cb; } }; + const ctx = dom.getInternalVMContext(); + const run = (src, filename) => vm.runInContext(src, ctx, { filename }); + run(PRELUDE, 'prelude.js'); + run(sliceBlock('function updateRunningIndicators() {'), 'app.js#updateRunningIndicators'); + run(`function updateTerminalHeader() {}`, 'stub.js'); + run(sliceBlock('window.api.onProcessExited((', ');'), 'app.js#onProcessExited'); + return { + exit: (id) => exitHandler(id, 0, null, true), + read: (expr) => run(expr, 'read.js'), + window, + }; +} + +test('a busy signal that lands after the pty-set scan is cleared when the session is dropped', () => { + const ctx = setupSidebarDom(); + try { + const item = ctx.document.createElement('div'); + item.className = 'session-item'; + item.dataset.sessionId = 'dropped-1'; + item.innerHTML = ''; + ctx.document.getElementById('sidebar-content').append(item); + + ctx.setActivity('dropped-1', true, 'onCliBusyState'); + assert.ok(item.classList.contains('cli-busy'), 'precondition: the late busy signal marked the row'); + + ctx.window.dropLocalPtySession('dropped-1', 'process-exited'); + + assert.ok(!item.classList.contains('cli-busy'), 'the dropped row must stop reading as busy'); + assert.equal(ctx.sessionBusyState.has('dropped-1'), false, 'no activity state survives the drop'); + } finally { + ctx.destroy(); + } +}); + +test('dropping a session also clears its attention mark', () => { + const ctx = setupSidebarDom(); + try { + const item = ctx.document.createElement('div'); + item.className = 'session-item'; + item.dataset.sessionId = 'dropped-2'; + item.innerHTML = ''; + ctx.document.getElementById('sidebar-content').append(item); + + ctx.window.setAttention('dropped-2', true, 'onTerminalNotification'); + assert.ok(item.classList.contains('needs-attention'), 'precondition'); + + ctx.window.dropLocalPtySession('dropped-2', 'process-exited'); + + assert.ok(!item.classList.contains('needs-attention')); + assert.equal(ctx.attentionSessions.has('dropped-2'), false); + } finally { + ctx.destroy(); + } +}); + +test('process-exited removes the session from the running set and drops its activity state', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); sessionMap.set('b', { sessionId: 'b', type: 'claude' })"); + h.exit('b'); + assert.equal(h.read('JSON.stringify(Array.from(activePtyIds))'), '["a"]', 'b no longer counts as running'); + assert.equal(h.read('JSON.stringify(calls.dropped)'), '[["b","process-exited"]]'); +}); + +test('process-exited leaves a remote row to its own adapter', () => { + const h = setup(); + h.read("sessionMap.set('r', { sessionId: 'r', type: 'claude', remoteAlias: 'vps' })"); + h.exit('r'); + assert.equal(h.read('calls.dropped.length'), 0, 'a remote row is not purged by the local drop path'); +}); + +test('a change in the running set refreshes the status bar count, an unchanged one does not', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators()"); + assert.equal(h.read('calls.status'), 1, 'set changed: status bar redrawn'); + h.read('updateRunningIndicators()'); + assert.equal(h.read('calls.status'), 1, 'set unchanged: no redraw'); + h.read("activePtyIds = new Set(['a']); updateRunningIndicators()"); + assert.equal(h.read('calls.status'), 2, 'a session dropped by main: status bar redrawn'); +}); + +test('process-exited refreshes the status bar through the running-set change', () => { + const h = setup(); + h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators(); sessionMap.set('b', { sessionId: 'b', type: 'claude' })"); + const before = h.read('calls.status'); + h.exit('b'); + assert.equal(h.read('calls.status'), before + 1); +}); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 256c4ea9..70326faf 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -64,6 +64,8 @@ const PRELUDE = ` function destroySession(id) { openSessions.delete(id); } function setActiveSession() {} function refreshSidebar() {} + function dropLocalPtySession() {} + function updateRunningIndicators() {} function schedulePersistWorkingSet() {} function pollActiveSessions() { updateTerminalHeader(); } async function guardResume() { return true; } diff --git a/test/running-indicators.test.js b/test/running-indicators.test.js index 50f0fbe4..2c496888 100644 --- a/test/running-indicators.test.js +++ b/test/running-indicators.test.js @@ -470,6 +470,6 @@ test('public/app.js: pty-stop cleanup removes has-busy-agents and purges the sid // the only place allowed to touch this class (eslint.config.js). assert.match(body, /setHasBusyAgents\(item,\s*false\)/, "the !running cleanup must clear 'has-busy-agents' along with the other per-session state classes"); - assert.match(body, /clearActiveSubagentsFor\(id\)/, - 'the !running cleanup must purge activeSubagentsByParent via clearActiveSubagentsFor so a re-render cannot resurrect the indicator'); + assert.match(body, /dropLocalPtySession\(id,\s*'pty-gone'\)/, + 'the !running cleanup must purge activeSubagentsByParent via dropLocalPtySession so a re-render cannot resurrect the indicator'); }); diff --git a/test/session-activity.test.js b/test/session-activity.test.js index 328a6868..d7ec1c26 100644 --- a/test/session-activity.test.js +++ b/test/session-activity.test.js @@ -478,8 +478,8 @@ test('public/app.js: the pty-stop cleanup routes through purgeActivityFor and sk assert.match(body, /if\s*\(!running\s*&&\s*!item\.dataset\.remoteAlias\)\s*\{/, 'the purge branch must skip rows carrying dataset.remoteAlias'); - assert.match(body, /purgeActivityFor\(id,\s*'pty-gone'\)/, - 'the purge must go through the shared dispatcher'); + assert.match(body, /dropLocalPtySession\(id,\s*'pty-gone'\)/, + 'the purge must go through the shared drop helper'); assert.ok(!/sessionBusyState\.delete\(id\)/.test(body), 'app.js must not delete from sessionBusyState directly anymore'); assert.ok(!/responseReadySessions\.delete\(id\)/.test(body), From ecbeb6c9bcba9262eeed8db1830418b02682a04b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 22:18:55 +0200 Subject: [PATCH 2/3] (sidebar): keep a reopened pty out of the process-exited drop An exit landing while the same id is being reopened may belong to the old pty, so the drop is skipped until openTerminal answers. The remote guard reads the sidebar row, like the pty-gone scan, and the dead typeof guard on clearActiveSubagentsFor is removed. Refs #375 --- public/app.js | 10 +++++++--- public/session-activity.js | 2 +- test/dropped-session-state.test.js | 13 +++++++++++-- test/process-exit-status.test.js | 11 +++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index bec4fcd5..53284bf6 100644 --- a/public/app.js +++ b/public/app.js @@ -510,9 +510,11 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { } noteSessionExit(sessionId, exitCode, signal, stopped); // see .ai/contexts/session-state.md ("A session main drops") - activePtyIds.delete(sessionId); - if (!session?.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); - updateRunningIndicators(); + if (!entry?.opening) { + activePtyIds.delete(sessionId); + if (!sessionItemEl(sessionId)?.dataset.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); + updateRunningIndicators(); + } const exit = lastSessionExit(sessionId); if (entry) { entry.closed = true; @@ -1229,7 +1231,9 @@ async function openSession(session, customOptions, { automatic = false, live } = const resumeOptions = customOptions || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); forgetSessionExit(sessionId); + entry.opening = true; const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); + entry.opening = false; if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; diff --git a/public/session-activity.js b/public/session-activity.js index 72eed2c1..25829e9c 100644 --- a/public/session-activity.js +++ b/public/session-activity.js @@ -99,7 +99,7 @@ function purgeActivityFor(sessionId, via) { // see .ai/contexts/session-state.md ("A session main drops") function dropLocalPtySession(sessionId, via) { - if (typeof clearActiveSubagentsFor === 'function') clearActiveSubagentsFor(sessionId); + clearActiveSubagentsFor(sessionId); purgeActivityFor(sessionId, via); } diff --git a/test/dropped-session-state.test.js b/test/dropped-session-state.test.js index 2c7c7125..5d8e0381 100644 --- a/test/dropped-session-state.test.js +++ b/test/dropped-session-state.test.js @@ -63,6 +63,7 @@ const PRELUDE = ` function localTranscriptPtyTakeover() {} function paintSessionIcon() {} function isSessionAlive() { return false; } + function sessionItemEl(id) { return document.querySelector('.session-item[data-session-id="' + id + '"]'); } function dropLocalPtySession(id, via) { calls.dropped.push([id, via]); } function renderDefaultStatus() { calls.status++; } `; @@ -135,13 +136,21 @@ test('process-exited removes the session from the running set and drops its acti assert.equal(h.read('JSON.stringify(calls.dropped)'), '[["b","process-exited"]]'); }); -test('process-exited leaves a remote row to its own adapter', () => { +test('process-exited leaves a remote row to its own adapter, even with no sessionMap entry', () => { const h = setup(); - h.read("sessionMap.set('r', { sessionId: 'r', type: 'claude', remoteAlias: 'vps' })"); + h.read("const row = document.createElement('div'); row.className = 'session-item'; row.dataset.sessionId = 'r'; row.dataset.remoteAlias = 'vps'; document.body.append(row)"); h.exit('r'); assert.equal(h.read('calls.dropped.length'), 0, 'a remote row is not purged by the local drop path'); }); +test('an exit that lands while the same id is being reopened does not drop the new pty', () => { + const h = setup(); + h.read("activePtyIds = new Set(['b']); sessionMap.set('b', { sessionId: 'b', type: 'claude' }); openSessions.set('b', { closed: false, opening: true, terminal: { write() {} } })"); + h.exit('b'); + assert.equal(h.read('JSON.stringify(Array.from(activePtyIds))'), '["b"]', 'the new pty stays in the running set'); + assert.equal(h.read('calls.dropped.length'), 0, 'its live state is not purged'); +}); + test('a change in the running set refreshes the status bar count, an unchanged one does not', () => { const h = setup(); h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators()"); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 70326faf..9112ae43 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -65,6 +65,7 @@ const PRELUDE = ` function setActiveSession() {} function refreshSidebar() {} function dropLocalPtySession() {} + function sessionItemEl() { return null; } function updateRunningIndicators() {} function schedulePersistWorkingSet() {} function pollActiveSessions() { updateTerminalHeader(); } @@ -134,6 +135,16 @@ test('a relaunch that fails to open shows no exit code from the process before i assert.equal(h.title(), 'Stopped'); }); +test('a relaunch marks its entry as opening until openTerminal answers', async () => { + const h = setup(); + h.exit(1, null); + let during = null; + h.whileOpening = () => { during = h.read("openSessions.get('s1').opening"); }; + await h.read("openSession(sessionMap.get('s1'))"); + assert.equal(during, true, 'an exit landing now may belong to the old pty'); + assert.equal(h.read("openSessions.get('s1').opening"), false, 'cleared once main answered'); +}); + test('a relaunch that opens shows no old exit code before its first poll', async () => { const h = setup(); h.exit(1, null); From 54c6ed3f4e2d26aa1153d3949b826368591be60c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 23:00:52 +0200 Subject: [PATCH 3/3] (sidebar): tell a stale pty exit from the current one by generation The opening flag skipped a genuine fast-failing exit and let a stale exit that arrived after the reply through. Main now numbers every spawn, returns the number in the open-terminal reply and sends it with process-exited; the renderer ignores an exit older than the last reply and buffers one that lands during the await until the reply says which pty it belonged to. Refs #375 --- .ai/contexts/session-state.md | 12 ++++++ main.js | 14 ++++--- preload.js | 2 +- public/app.js | 55 ++++++++++++++++++++++----- test/dropped-session-state.test.js | 12 ++---- test/process-exit-status.test.js | 59 +++++++++++++++++++++++------ test/restore-live-elsewhere.test.js | 2 + 7 files changed, 122 insertions(+), 34 deletions(-) diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 1ca50bd7..f83c4922 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -530,6 +530,18 @@ redrawn by `loadProjects()`. - `updateRunningIndicators()` calls `renderDefaultStatus()` whenever the set changed, so the status bar count follows `activePtyIds`. +**Pty generation.** `wireSessionPty` (`main.js`) gives every spawn the next value of +a monotonically increasing counter (`session.generation`). It is returned by all +three `open-terminal` replies and sent as the fifth argument of every +`process-exited` (the fork re-key's second send included). `app.js` records the +generation of the last reply per id (`ptyGenerations`) and `handleProcessExited` +ignores an exit older than it — no drop, no `closed`, no banner. An exit that +lands while `openSession` awaits the reply is buffered (`pendingOpens`); +`settlePtyOpen` then applies it unless the reply's generation is newer, so a +stale exit never touches the new pty and a fast-failing launch still reads +as exited. Chosen over suppressing the event in main because main cannot know +which exit the renderer has already seen when it is delivered. + Remote rows stay with the remote adapter (see "Row ownership"). Not covered: a busy signal that arrives after `process-exited` — main sends none, the PTY is gone. The pre-fix sequence is reasoned from the code, not reproduced live; diff --git a/main.js b/main.js index 795ca5e8..e19bf915 100644 --- a/main.js +++ b/main.js @@ -2132,7 +2132,11 @@ function sandboxBindEnv(dirs) { } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") +let ptyGenerationCounter = 0; + +// see .ai/contexts/session-state.md ("A session main drops") function wireSessionPty(session, sessionId, ptyProcess) { + session.generation = ++ptyGenerationCounter; ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; @@ -2235,12 +2239,12 @@ function wireSessionPty(session, sessionId, ptyProcess) { const realId = session.realSessionId || sessionId; if (TRACE.on) trace('pty.exit', realId, { exitCode, signal: exitSignal, stopped, alsoUnder: realId !== sessionId ? sessionId : null, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) }); if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped); + mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped, session.generation); // If a fork transition re-keyed this session under realId but the PTY // exited before transition detection ran, also notify the renderer for // the original sessionId so it doesn't stay stuck as "Running". if (realId !== sessionId && activeSessions.has(sessionId)) { - mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped); + mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped, session.generation); } } activeSessions.delete(realId); @@ -2277,7 +2281,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se mainWindow.webContents.send('terminal-data', sessionId, '\x1b[?25l'); } - return { ok: true, reattached: true, mcpActive: !!session.mcpServer, sandbox: !!session.sandbox }; + return { ok: true, reattached: true, mcpActive: !!session.mcpServer, sandbox: !!session.sandbox, generation: session.generation }; } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") @@ -2311,7 +2315,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se }; activeSessions.set(sessionId, remoteSession); wireSessionPty(remoteSession, sessionId, attachResult.ptyProcess); - return { ok: true, reattached: false, remote: true, sandbox: false }; + return { ok: true, reattached: false, remote: true, sandbox: false, generation: remoteSession.generation }; } } @@ -2614,7 +2618,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se log.info(`[fork-spawn] tempId=${sessionId} forkFrom=${sessionOptions.forkFrom} folder=${projectFolder} knownFiles=${knownJsonlFiles.size}`); } - return { ok: true, reattached: false, mcpActive: !!mcpServer, sandbox: !!sessionOptions?.sandbox }; + return { ok: true, reattached: false, mcpActive: !!mcpServer, sandbox: !!sessionOptions?.sandbox, generation: session.generation }; }); // --- IPC: activity-trace (fire-and-forget, opt-in) --- diff --git a/preload.js b/preload.js index c2dd3d0d..091d4274 100644 --- a/preload.js +++ b/preload.js @@ -110,7 +110,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('session-detected', (_event, tempId, realId) => callback(tempId, realId)); }, onProcessExited: (callback) => { - ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped) => callback(sessionId, exitCode, signal, stopped)); + ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped, generation) => callback(sessionId, exitCode, signal, stopped, generation)); }, onTerminalNotification: (callback) => { ipcRenderer.on('terminal-notification', (_event, sessionId, message) => callback(sessionId, message)); diff --git a/public/app.js b/public/app.js index 53284bf6..2908019a 100644 --- a/public/app.js +++ b/public/app.js @@ -125,6 +125,8 @@ let showTodayOnly = false; let cachedProjects = []; let cachedAllProjects = []; let activePtyIds = new Set(); +const ptyGenerations = new Map(); +const pendingOpens = new Map(); let sortedOrder = []; // [{ projectPath, itemIds: [itemId, ...] }, ...] — single source of truth for sidebar order let activeTab = 'sessions'; let visibleSessionCount = SETTING_DEFAULTS.visibleSessionCount; @@ -498,7 +500,7 @@ window.api.onSessionForked((oldId, newId) => { pollActiveSessions(); }); -window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { +function applyProcessExit(sessionId, exitCode, signal, stopped) { if (window.ATRACE) window.atrace('recv.process-exited', sessionId, { exitCode, signal, stopped }); const entry = openSessions.get(sessionId); const session = sessionMap.get(sessionId); @@ -510,11 +512,9 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { } noteSessionExit(sessionId, exitCode, signal, stopped); // see .ai/contexts/session-state.md ("A session main drops") - if (!entry?.opening) { - activePtyIds.delete(sessionId); - if (!sessionItemEl(sessionId)?.dataset.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); - updateRunningIndicators(); - } + activePtyIds.delete(sessionId); + if (!sessionItemEl(sessionId)?.dataset.remoteAlias) dropLocalPtySession(sessionId, 'process-exited'); + updateRunningIndicators(); const exit = lastSessionExit(sessionId); if (entry) { entry.closed = true; @@ -568,6 +568,37 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => { // Claude session exited → update persisted working set (entry.closed=true → excluded from set) schedulePersistWorkingSet(); pollActiveSessions(); +} + +// see .ai/contexts/session-state.md ("A session main drops") +function handleProcessExited(sessionId, exitCode, signal, stopped, generation) { + const recorded = ptyGenerations.get(sessionId); + if (typeof generation === 'number' && typeof recorded === 'number' && generation < recorded) return; + const pending = pendingOpens.get(sessionId); + if (pending) { + pending.push({ args: [sessionId, exitCode, signal, stopped], generation }); + return; + } + applyProcessExit(sessionId, exitCode, signal, stopped); +} + +function beginPtyOpen(sessionId) { + pendingOpens.set(sessionId, []); +} + +function settlePtyOpen(sessionId, result) { + const buffered = pendingOpens.get(sessionId) || []; + pendingOpens.delete(sessionId); + const generation = result && result.generation; + if (typeof generation === 'number') ptyGenerations.set(sessionId, generation); + for (const exit of buffered) { + if (typeof generation === 'number' && typeof exit.generation === 'number' && exit.generation < generation) continue; + applyProcessExit(...exit.args); + } +} + +window.api.onProcessExited((sessionId, exitCode, signal, stopped, generation) => { + handleProcessExited(sessionId, exitCode, signal, stopped, generation); }); // --- Terminal notifications (iTerm2 OSC 9 — "needs attention") --- @@ -1231,9 +1262,15 @@ async function openSession(session, customOptions, { automatic = false, live } = const resumeOptions = customOptions || (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath })); forgetSessionExit(sessionId); - entry.opening = true; - const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); - entry.opening = false; + beginPtyOpen(sessionId); + let result; + try { + result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize); + } catch (err) { + settlePtyOpen(sessionId, null); + throw err; + } + settlePtyOpen(sessionId, result); if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; diff --git a/test/dropped-session-state.test.js b/test/dropped-session-state.test.js index 5d8e0381..fbe89c2c 100644 --- a/test/dropped-session-state.test.js +++ b/test/dropped-session-state.test.js @@ -34,6 +34,8 @@ function sliceBlock(marker, tail = '') { const PRELUDE = ` let activePtyIds = new Set(); let _lastPtySignature = ''; + const ptyGenerations = new Map(); + const pendingOpens = new Map(); const calls = { dropped: [], status: 0, indicators: 0 }; const openSessions = new Map(); const sessionMap = new Map(); @@ -78,6 +80,8 @@ function setup() { run(PRELUDE, 'prelude.js'); run(sliceBlock('function updateRunningIndicators() {'), 'app.js#updateRunningIndicators'); run(`function updateTerminalHeader() {}`, 'stub.js'); + run(sliceBlock('function applyProcessExit('), 'app.js#applyProcessExit'); + run(sliceBlock('function handleProcessExited('), 'app.js#handleProcessExited'); run(sliceBlock('window.api.onProcessExited((', ');'), 'app.js#onProcessExited'); return { exit: (id) => exitHandler(id, 0, null, true), @@ -143,14 +147,6 @@ test('process-exited leaves a remote row to its own adapter, even with no sessio assert.equal(h.read('calls.dropped.length'), 0, 'a remote row is not purged by the local drop path'); }); -test('an exit that lands while the same id is being reopened does not drop the new pty', () => { - const h = setup(); - h.read("activePtyIds = new Set(['b']); sessionMap.set('b', { sessionId: 'b', type: 'claude' }); openSessions.set('b', { closed: false, opening: true, terminal: { write() {} } })"); - h.exit('b'); - assert.equal(h.read('JSON.stringify(Array.from(activePtyIds))'), '["b"]', 'the new pty stays in the running set'); - assert.equal(h.read('calls.dropped.length'), 0, 'its live state is not purged'); -}); - test('a change in the running set refreshes the status bar count, an unchanged one does not', () => { const h = setup(); h.read("activePtyIds = new Set(['a', 'b']); updateRunningIndicators()"); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 9112ae43..50764757 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -56,7 +56,7 @@ const PRELUDE = ` const placeholder = document.createElement('div'); const terminalHeaderStatus = document.createElement('span'); const terminalStopBtn = document.createElement('button'); - const calls = { writes: [], opened: [] }; + const calls = { writes: [], opened: [], dropped: [] }; let openResult = { ok: true }; function updatePtyTitle() {} function isPanelTerminalSession() { return false; } @@ -64,8 +64,10 @@ const PRELUDE = ` function destroySession(id) { openSessions.delete(id); } function setActiveSession() {} function refreshSidebar() {} - function dropLocalPtySession() {} + function dropLocalPtySession(id) { calls.dropped.push(id); } function sessionItemEl() { return null; } + const ptyGenerations = new Map(); + const pendingOpens = new Map(); function updateRunningIndicators() {} function schedulePersistWorkingSet() {} function pollActiveSessions() { updateTerminalHeader(); } @@ -97,11 +99,15 @@ function setup() { run(fs.readFileSync(path.join(ROOT, 'public', 'process-exit.js'), 'utf8'), 'process-exit.js'); run(sliceBlock('function updateTerminalHeader() {'), 'app.js#updateTerminalHeader'); run(sliceBlock('async function openSession(session'), 'app.js#openSession'); + run(sliceBlock('function applyProcessExit('), 'app.js#applyProcessExit'); + run(sliceBlock('function handleProcessExited('), 'app.js#handleProcessExited'); + run(sliceBlock('function beginPtyOpen('), 'app.js#beginPtyOpen'); + run(sliceBlock('function settlePtyOpen('), 'app.js#settlePtyOpen'); run(sliceBlock('window.api.onProcessExited((', ');'), 'app.js#onProcessExited'); run(`sessionMap.set('s1', { sessionId: 's1', projectPath: '/p', type: 'claude' }); openSessions.set('s1', makeEntry());`, 'fixture.js'); const read = (expr) => run(expr, 'read.js'); return Object.assign(h, { - exit: (code, signal, stopped) => exitHandler('s1', code, signal, stopped), + exit: (code, signal, stopped, generation) => exitHandler('s1', code, signal, stopped, generation), exitOf: (id, code) => exitHandler(id, code, null, false), poll: (running) => { read(running ? "activePtyIds.add('s1')" : "activePtyIds.delete('s1')"); read('updateTerminalHeader()'); }, title: () => read('terminalHeaderStatus.title'), @@ -135,14 +141,43 @@ test('a relaunch that fails to open shows no exit code from the process before i assert.equal(h.title(), 'Stopped'); }); -test('a relaunch marks its entry as opening until openTerminal answers', async () => { +test('a stale exit that arrives after the reply of the relaunch leaves the new pty alone', async () => { const h = setup(); - h.exit(1, null); - let during = null; - h.whileOpening = () => { during = h.read("openSessions.get('s1').opening"); }; + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + await h.read("openSession(sessionMap.get('s1'))"); + h.read("activePtyIds.add('s1')"); + const writes = h.read('calls.writes.length'); + const dropped = h.read('calls.dropped.length'); + h.exit(9, null, false, 1); + assert.equal(h.read("openSessions.get('s1').closed"), false, 'the new pty is not marked closed'); + assert.equal(h.read('calls.writes.length'), writes, 'no banner for the old pty'); + assert.equal(h.read("activePtyIds.has('s1')"), true, 'the new pty stays in the running set'); + assert.equal(h.read('calls.dropped.length'), dropped, 'its live state is not purged'); +}); + +test('a stale exit that arrives before the reply is discarded once the reply names a newer generation', async () => { + const h = setup(); + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + h.whileOpening = () => h.exit(9, null, false, 1); await h.read("openSession(sessionMap.get('s1'))"); - assert.equal(during, true, 'an exit landing now may belong to the old pty'); - assert.equal(h.read("openSessions.get('s1').opening"), false, 'cleared once main answered'); + h.read("activePtyIds.add('s1')"); + assert.equal(h.read("openSessions.get('s1').closed"), false, 'the new pty is not marked closed'); + assert.equal(h.read("activePtyIds.has('s1')"), true); +}); + +test('a fast-failing launch whose exit lands during the await is applied after the reply', async () => { + const h = setup(); + h.exit(1, null, false, 1); + h.read("openResult = { ok: true, generation: 2 }"); + h.whileOpening = () => h.exit(3, null, false, 2); + const droppedBefore = h.read('calls.dropped.length'); + await h.read("openSession(sessionMap.get('s1'))"); + assert.equal(h.read("openSessions.get('s1').closed"), true, 'the exit of the current pty closes its entry'); + assert.equal(h.title(), 'Exited (code 3)'); + assert.match(h.lastWrite(), /session exited \(code 3\)/); + assert.equal(h.read('calls.dropped.length'), droppedBefore + 1, 'and drops its state'); }); test('a relaunch that opens shows no old exit code before its first poll', async () => { @@ -214,12 +249,14 @@ test('main names the signal node-pty reports and forwards it with the exit code' assert.match(MAIN_SRC, /ptyProcess\.onExit\(\(\{ exitCode, signal \}\) => \{\s*const exitSignal = ptyExitSignalName\(signal\);/); const sends = MAIN_SRC.match(/webContents\.send\('process-exited', [^)]*\)/g); assert.equal(sends.length, 2); - for (const send of sends) assert.match(send, /, exitCode, exitSignal, stopped\)$/); + for (const send of sends) assert.match(send, /, exitCode, exitSignal, stopped, session\.generation\)$/); assert.match(MAIN_SRC, /const stopped = !!session\.stopRequested;/); const stopHandler = MAIN_SRC.slice(MAIN_SRC.indexOf("ipcMain.handle('stop-session'"), MAIN_SRC.indexOf("ipcMain.handle('remote-stop-session'")); assert.match(stopHandler, /session\.stopRequested = true;\s*killPty\(session, sessionId\);/, 'a Stop is recorded before the signal is sent'); assert.match(MAIN_SRC, /attachedSession\.stopRequested = true;\s*killPty\(attachedSession, sessionId\);/); - assert.match(PRELOAD_SRC, /'process-exited', \(_event, sessionId, exitCode, signal, stopped\) => callback\(sessionId, exitCode, signal, stopped\)/); + assert.ok(MAIN_SRC.includes('session.generation = ++ptyGenerationCounter;'), 'every wired pty gets the next generation'); + assert.equal(MAIN_SRC.match(/return \{ ok: true, reattached: (true|false), [^}]*generation: (session|remoteSession)\.generation \}/g).length, 3, 'all three open-terminal replies carry the generation'); + assert.ok(PRELOAD_SRC.includes("'process-exited', (_event, sessionId, exitCode, signal, stopped, generation) => callback(sessionId, exitCode, signal, stopped, generation)")); assert.match(APP_SRC, /notePanelTerminalExit\(sessionId, exitCode, signal, stopped\)/); }); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index b3220dc5..ac73f0b7 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -82,6 +82,8 @@ function setup({ savedSet, liveIds, batchFails = false }) { function setSessionMcpActive() {} function setSessionSandboxed() {} function forgetSessionExit() {} + function beginPtyOpen() {} + function settlePtyOpen() {} function schedulePersistWorkingSet() {} function pollActiveSessions() {} `, ctx);