From c67c130caa6b6962932b0d46704b0778356274d7 Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Mon, 21 Sep 2026 10:01:22 +0200 Subject: [PATCH] feat(web): mark a session tab whose agent has exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab now reads "exited (137)" beside the session name, drawn from the `paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM work, called from the incremental render path — the only path a live session ever takes, since going from live to exited adds and removes no tab and so never reaches the full rebuild. An unknown answer draws nothing. A death tmux could not explain reads "exited" with no number rather than "exited (0)", so an unexplained death and a clean exit do not look alike. A signal death reads "exited (signal 9)". The badge carries `data-i18n-skip`, like the status pills: it is generated text, `i18n.js` walks inserted content, and a dictionary entry added later would fight the renderer, whose in-place comparison is against English. The tab also carries a `tab-agent-exited` class that mutes the status dot. That dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as the issue requires, so without this a green or pulsing dot sits beside a badge saying the agent is gone — the first thing a tester asked about. `status` itself is untouched, so this is a rendering rule only. The CSS excludes the two alert classes by hand, following the convention the rich-rail dot rules document: a dot turning red or yellow because a session is blocked on a human outranks "the agent exited". The tab keeps its click behavior. X still closes it, and nothing here closes, sweeps or restarts anything. `docs/architecture-invariants.md` gains the mechanism under "Session data and lifecycle", where every comparable one already lives: what the tri-state means, the four shapes it is absent for, why the watcher cannot ride the stats collector, why an absent `#{pane_dead_status}` is not 0, and the three things that must never happen to an exited pane. Refs Ark0N/Codeman#446. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture-invariants.md | 4 + src/web/public/app.js | 62 ++++++++++++- src/web/public/styles.css | 40 ++++++++ test/session-pane-exit-ui.test.ts | 147 ++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 test/session-pane-exit-ui.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index b024875a..66d19f89 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -162,6 +162,10 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do **Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. **Distinct: PTY-exit breaker** (COD-115/118/#147, `session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE `session:respawnBreakerTripped` + push (in `PUSH_EVENT_MAP`). Reset ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive` (sent by the user-facing restart control) — the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. Sessions also scrub inherited `TMUX`/`TMUX_PANE` env so Codeman-in-tmux doesn't nest. Tests: `test/respawn-pty-breaker.test.ts`. +### An exited agent in a live pane (`paneExit`) + +**Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.test.ts`. + ## Features ### Attachments diff --git a/src/web/public/app.js b/src/web/public/app.js index 4b27df17..4762d978 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -325,6 +325,55 @@ function parseSessionPrefix(name) { return null; } +// ═══════════════════════════════════════════════════════════════ +// Exited-agent tab label (Ark0N/Codeman#446) +// ═══════════════════════════════════════════════════════════════ +// The server publishes session.paneExit when the agent inside a local tmux +// pane has exited while remain-on-exit kept the pane. The field is tri-state +// and its third state is absence, which means Codeman does not know — that +// renders as nothing here and must never read as alive. +// +// status and signal are each optional, because tmux can know the pane died +// without reporting how (a SIGKILLed pane on tmux 3.2a reports neither). So an +// absent status shows a bare "exited" rather than "exited (0)": a clean exit +// and an unexplained one must not look the same. +function paneExitLabel(paneExit) { + if (!paneExit || typeof paneExit !== 'object') return ''; + if (typeof paneExit.signal === 'number' && paneExit.signal > 0) return `exited (signal ${paneExit.signal})`; + if (typeof paneExit.status === 'number') return `exited (${paneExit.status})`; + return 'exited'; +} + +// Add, update or remove one tab's exited-agent badge in place. Separate from +// the render loop so it can be exercised directly: this is the only path a +// session going live-to-exited ever takes, since that transition adds and +// removes no tab and so never reaches the full rebuild. +function applyPaneExitBadge(tab, paneExit) { + const label = paneExitLabel(paneExit); + const existing = tab.querySelector('.tab-exited-badge'); + // Quiets the status dot too. That dot reports `status`, which stays `idle` or + // `busy` for an exited pane by design, so without this a green or pulsing dot + // sits next to a badge saying the agent is gone. + tab.classList.toggle('tab-agent-exited', !!label); + if (!label) { + existing?.remove(); + return; + } + if (!existing) { + const badge = document.createElement('span'); + badge.className = 'tab-exited-badge'; + // Generated status text, like the status pills: it carries data-i18n-skip + // rather than a dictionary entry. Without it the translator would rewrite + // the badge and the next render pass would rewrite it back, because the + // comparison below is against the English string. + badge.setAttribute('data-i18n-skip', ''); + badge.textContent = label; + tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge); + return; + } + if (existing.textContent !== label) existing.textContent = label; +} + const DEFAULT_SHORTCUTS = [ { id: 'show-shortcuts', @@ -4968,6 +5017,11 @@ class CodemanApp { statusEl.className = `tab-status ${status}`; } + // The exited-agent badge (Ark0N/Codeman#446). A session going from live + // to exited changes no tab count, so the full rebuild below never runs + // for it and this is the only path that ever draws the badge. + applyPaneExitBadge(tab, session.paneExit); + // Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps // themselves move on _tickSidebarRichTimes(); this is here for the parts // a tick cannot see — the state flipping, and with it the pill, the row @@ -5268,10 +5322,15 @@ class CodemanApp { ? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"` : ''; + // '' whenever the server said nothing about this pane's agent, which covers + // a running pane and every session shape the field never applies to + // (direct-PTY, remote SSH, docker). See paneExitLabel(). + const paneExitBadge = paneExitLabel(session.paneExit); + const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - parts.push(`