mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #466 from irisitymichaelgrundberg/feat/pane-exit-reporting
feat(tmux): report that a pane's agent has exited (#446, part 1)
This commit is contained in:
@@ -203,6 +203,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
|
||||
|
||||
**An exited agent in a live pane** (`paneExit`, Ark0N/Codeman#446): Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI while tmux keeps the pane, the tmux session and the `tmux attach-session` process Codeman records as `Session.pid`. No PTY exit handler fires, so a session whose agent is gone reads as a live idle one. `TmuxManager.startPaneExitWatcher()` reads `#{pane_dead}`/`#{pane_dead_status}`/`#{pane_dead_signal}` from one batched `list-panes -a` on its OWN always-on interval, and `SessionState.paneExit` rides the existing `session:updated` broadcast. ⚠️ **Never set `status: 'error'` for an exited pane** — that value belongs to the PTY-exit breaker and makes the browser offer a restart — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which must never render as alive; `Session.paneExitApplies` is the one place that scoping lives and it fails closed for a direct-PTY session, a remote SSH session, a docker case and a record rebuilt from the socket. ⚠️ **An absent `#{pane_dead_status}` is not 0** (measured on tmux 3.2a, a SIGKILLed pane reports neither a status nor a signal), so never write `status ?? 0`: absent-stays-absent is what will keep a later clean-exit sweep off crashed agents. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
|
||||
|
||||
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. ⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). ⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago. ⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
|
||||
|
||||
**Process-tree walks are bounded** (`proc-tree.ts`, pure + unit tested): `collectDescendants(pid, byParent)` is the ONE descendant traversal, fed by a single cached `ps -eo pid=,ppid=` snapshot (`refreshProcSnapshot()` in tmux-manager.ts: in-flight-shared, async because `execSync`'s timeout cannot return at all while spawnSync waits on an unkillable child, and ANY error discards the result rather than caching a truncated `ps`, which would make whole subtrees invisible to the kill path). ⚠️ **The unbounded version took a machine down** (2026-07-30): it ran `pgrep -P <pid>` once per node and recursed with no visited set, no depth limit and no node cap, so across ~28 adopted tmux trees the fan-out exploded while each `pgrep` blocked in the WSL kernel reading `/proc/<pid>/cgroup`, ending at ~13,000 `pgrep` processes in D-state, a load average above 13,000, and a machine recoverable only by restarting WSL, which cost every running session. Three properties make that impossible and each has a test: a cycle terminates (a real tree has none, a stale snapshot can still produce one), depth is capped (`PROC_WALK_MAX_DEPTH`), node count is capped (`PROC_WALK_MAX_NODES`). The fourth is structural: the function takes a snapshot and cannot spawn anything at all. ⚠️ It lives in its own module because as a private method of `tmux-manager.ts` the regression test had to keep its own COPY of the algorithm, which is a test that passes while the shipped code rots. ⚠️ Truncation is reported through `onTruncated` rather than silently, with BOTH caps named: a silent depth cap hides a deep tree exactly as effectively as a silent node cap hides a wide one.
|
||||
|
||||
@@ -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-<fragment>` 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. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.test.ts`, `test/session-pane-exit-ui.test.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
### Attachments
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
PaneExit,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -56,6 +57,23 @@ export interface MuxSession {
|
||||
respawnConfig?: PersistedRespawnConfig;
|
||||
/** Whether Ralph / Todo tracking is enabled */
|
||||
ralphEnabled?: boolean;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
|
||||
* entry either, which means a remote or docker session rediscovered this way
|
||||
* arrives with no `remote`/`docker` metadata and looks local. Anything that
|
||||
* would be WRONG about such a session rather than merely vague must fail
|
||||
* closed on this flag.
|
||||
*
|
||||
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
|
||||
* session: `saveSessions()` serializes the whole record to
|
||||
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
|
||||
* session rediscovered once stays opted out of everything keyed on this for
|
||||
* the life of that record. That is the safe direction to fail, and it costs
|
||||
* only the guess Codeman is declining to make.
|
||||
*/
|
||||
discovered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +198,7 @@ export interface PaneCaptureOptions {
|
||||
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
|
||||
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
|
||||
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
|
||||
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
|
||||
*/
|
||||
export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
@@ -294,6 +313,24 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
|
||||
isPaneDead(muxName: string): boolean;
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this session's agent, or `undefined` for
|
||||
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
|
||||
* reads a map the batched watcher fills, so it answers no fresher than that
|
||||
* watcher's interval and the three synchronous `isPaneDead()` callers still
|
||||
* need their own probe. See {@link PaneExit}.
|
||||
*/
|
||||
getPaneExit?(muxName: string): PaneExit | undefined;
|
||||
|
||||
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
|
||||
clearPaneExit?(muxName: string): void;
|
||||
|
||||
/** Start polling every pane on the socket for an exited agent. */
|
||||
startPaneExitWatcher?(intervalMs?: number): void;
|
||||
|
||||
/** Stop the pane-exit watcher. */
|
||||
stopPaneExitWatcher?(): void;
|
||||
|
||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||
|
||||
|
||||
+117
@@ -60,6 +60,7 @@ import {
|
||||
type SessionDocker,
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
@@ -544,6 +545,20 @@ export class Session extends EventEmitter {
|
||||
private _mux: TerminalMultiplexer | null = null;
|
||||
private _muxSession: MuxSession | null = null;
|
||||
private _useMux: boolean = false;
|
||||
/**
|
||||
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
|
||||
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
|
||||
* stores for every session shape the field does not apply to. See
|
||||
* {@link PaneExit} for the shapes and for why an unknown answer must never be
|
||||
* rendered as "alive".
|
||||
*/
|
||||
private _paneExit: PaneExit | null = null;
|
||||
/**
|
||||
* This session was rebuilt from the tmux socket rather than from Codeman's
|
||||
* own records, so its `remote`/`docker` metadata is missing rather than known
|
||||
* to be absent. See {@link MuxSession.discovered}.
|
||||
*/
|
||||
private _discoveredMuxSession = false;
|
||||
// Flag to prevent new timers after session is stopped
|
||||
private _isStopped: boolean = false;
|
||||
|
||||
@@ -720,6 +735,10 @@ export class Session extends EventEmitter {
|
||||
lastSubmitAt?: number;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
paneExit?: PaneExit;
|
||||
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
|
||||
discoveredMuxSession?: boolean;
|
||||
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
|
||||
lastActivityAt?: number;
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
@@ -872,6 +891,16 @@ export class Session extends EventEmitter {
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
this._discoveredMuxSession = config.discoveredMuxSession === true;
|
||||
// Restored so a record that says the agent exited survives a server restart
|
||||
// rather than being blanked by the first persist after boot. It runs here
|
||||
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
|
||||
// which are set by now. It is a claim about a pane this process has not
|
||||
// looked at yet, so every path that starts or re-attaches a pane drops it
|
||||
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
|
||||
// replaces it with a first-hand reading. NOT the stats collector, which a
|
||||
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
||||
this.setPaneExit(config.paneExit);
|
||||
// Never self-parent: a session pointing at itself would draw a zero-length
|
||||
// lineage arc under its own tab. Only reachable via the recovery path, where
|
||||
// both the id and the saved parent come from disk.
|
||||
@@ -1099,6 +1128,75 @@ export class Session extends EventEmitter {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a tmux pane's death would mean THIS session's agent has exited.
|
||||
*
|
||||
* Four shapes fail the test, and each would otherwise publish a death that is
|
||||
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
|
||||
* session's local pane holds the ssh client, whose death means a transport
|
||||
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
|
||||
* local pane holds a `docker exec` into the container's own tmux.
|
||||
*
|
||||
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
|
||||
* normally means "this is local", but on a discovered record it only means
|
||||
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
|
||||
* matches no `state.json` entry, so a remote session rediscovered after
|
||||
* `mux-sessions.json` was lost arrives looking local, and its next transport
|
||||
* drop would be published as an agent exit. Unproven locality fails closed.
|
||||
*/
|
||||
private get paneExitApplies(): boolean {
|
||||
if (this._discoveredMuxSession) return false;
|
||||
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
|
||||
}
|
||||
|
||||
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
|
||||
get paneExit(): PaneExit | undefined {
|
||||
return this._paneExit ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget this pane's exit, on both this record and the mux layer's cache.
|
||||
* Every path that starts or relaunches a command in the pane calls it, and
|
||||
* the mux half also invalidates a pane read already in flight.
|
||||
*
|
||||
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
|
||||
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
|
||||
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
|
||||
* reports no change and therefore writes nothing, so a caller that only
|
||||
* broadcasts leaves `state.json` saying the agent exited for as long as the
|
||||
* session stays quiet. `/interactive` and `/shell` did exactly that until
|
||||
* Ark0N/Codeman#446 review; both now persist on their success path.
|
||||
*/
|
||||
private clearPaneExitForNewPane(): void {
|
||||
this.setPaneExit(undefined);
|
||||
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the mux layer observed of this pane's agent, and say whether
|
||||
* that changed the answer. The caller persists and broadcasts on a true.
|
||||
*
|
||||
* A session the field does not apply to is forced to UNKNOWN here rather than
|
||||
* at the reporting end, so the rule lives in one place and the mux layer stays
|
||||
* free to report the raw pane reading its own remote-reconnect watcher needs.
|
||||
*/
|
||||
setPaneExit(next: PaneExit | undefined): boolean {
|
||||
const resolved = this.paneExitApplies ? (next ?? null) : null;
|
||||
const prev = this._paneExit;
|
||||
if (prev === resolved) return false;
|
||||
if (
|
||||
prev !== null &&
|
||||
resolved !== null &&
|
||||
prev.status === resolved.status &&
|
||||
prev.signal === resolved.signal &&
|
||||
prev.at === resolved.at
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this._paneExit = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this session's PTY is a tmux client rather than the program itself.
|
||||
* Read by the replay-side alt-screen strip, which must apply the same
|
||||
@@ -1622,6 +1720,10 @@ export class Session extends EventEmitter {
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
|
||||
// it rides the existing `session:updated` broadcast and lands in state.json
|
||||
// through the same persist. `status` and `pid` above stay untouched by it.
|
||||
paneExit: this._paneExit ?? undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
||||
@@ -1762,6 +1864,14 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the last reading said about the OLD command in this pane is now
|
||||
// history: the branch above either respawned the pane or found it alive, and
|
||||
// the branch below creates a new one. The paths that reach here are boot
|
||||
// recovery and an explicit start, NOT a click on an exited tab — the browser
|
||||
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
|
||||
// that an exited pane keeps its pid. `restartCli()` clears separately.
|
||||
this.clearPaneExitForNewPane();
|
||||
|
||||
// Check if we already have a mux session (restored session)
|
||||
const isRestored = this._muxSession !== null && !needsNewSession;
|
||||
if (isRestored) {
|
||||
@@ -1867,6 +1977,9 @@ export class Session extends EventEmitter {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
// No-op for the record (a remote session's field is always UNKNOWN), but the
|
||||
// mux layer's cache is keyed by muxName and this pane now runs a new client.
|
||||
this.clearPaneExitForNewPane();
|
||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
@@ -1907,6 +2020,10 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
this._pendingEnvUnsets.clear();
|
||||
// A relaunch in the same pane, so any exit observed of the previous command
|
||||
// is history. Without this the caller's persist-and-broadcast writes the old
|
||||
// exit straight back onto a session that is running again.
|
||||
this.clearPaneExitForNewPane();
|
||||
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
+365
-15
@@ -58,6 +58,7 @@ import {
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
type PaneExit,
|
||||
} from './types.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
@@ -152,6 +153,16 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* How often the pane-exit watcher re-reads every pane on the socket. The
|
||||
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
|
||||
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
|
||||
* Matched to the stats cadence above because both cost one batched tmux read.
|
||||
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
|
||||
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
|
||||
*/
|
||||
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
|
||||
|
||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||
|
||||
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
*/
|
||||
const PANE_LIST_SEP = '|';
|
||||
|
||||
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
|
||||
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
|
||||
/**
|
||||
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
|
||||
*
|
||||
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
|
||||
* Appending them is backward compatible in both directions. A tmux that does not
|
||||
* know a variable substitutes the empty string rather than failing, which is how
|
||||
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
|
||||
* short row as "pid known, deadness unknown" rather than discarding it.
|
||||
*/
|
||||
const PANE_LIST_FORMAT =
|
||||
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
|
||||
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
|
||||
|
||||
/**
|
||||
* 构建 pane 启动前的 nofile 修复命令。
|
||||
@@ -235,26 +256,141 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
|
||||
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
||||
}
|
||||
|
||||
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
|
||||
export interface PaneRow {
|
||||
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
|
||||
sessionName: string;
|
||||
/** `#{pane_pid}` — the process tmux started in the pane. */
|
||||
pid: number;
|
||||
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
|
||||
dead?: boolean;
|
||||
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
|
||||
exitStatus?: number;
|
||||
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
|
||||
exitSignal?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
|
||||
* into a Map of session-name → pane pid. Exported for unit testing.
|
||||
* One pane-exit reading, with the pane pid that produced it.
|
||||
*
|
||||
* The pid never leaves this module. It is what distinguishes "the same dead
|
||||
* pane, seen again" from "a second command in the same pane that also exited
|
||||
* with the same status", so the `at` stamp can hold across the first and must
|
||||
* not across the second. {@link PaneExit} itself stays free of it: the pid on
|
||||
* the session record is the attach client's, and a second pid there would
|
||||
* invite exactly the confusion Ark0N/Codeman#446 is about.
|
||||
*/
|
||||
export interface PaneExitObservation {
|
||||
/** `#{pane_pid}` of the pane this reading came from. */
|
||||
panePid: number;
|
||||
/** What to publish on the session record. */
|
||||
exit: PaneExit;
|
||||
}
|
||||
|
||||
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
|
||||
function paneField(fields: string[], index: number): number | undefined {
|
||||
const raw = fields[index];
|
||||
if (raw === undefined || raw === '') return undefined;
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
|
||||
* into one row per pane, in tmux's own order. Exported for unit testing.
|
||||
*
|
||||
* - Skips empty lines and lines without the separator.
|
||||
* - Skips entries with a non-numeric pid or empty name.
|
||||
* - Leaves every field after the pid undefined when it is blank or absent, so a
|
||||
* row from an older tmux still yields its pid.
|
||||
*/
|
||||
export function parsePaneList(output: string): Map<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
export function parsePaneRows(output: string): PaneRow[] {
|
||||
const rows: PaneRow[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) continue;
|
||||
const sep = line.indexOf(PANE_LIST_SEP);
|
||||
if (sep === -1) continue;
|
||||
const name = line.slice(0, sep);
|
||||
const pid = parseInt(line.slice(sep + 1), 10);
|
||||
if (name && !Number.isNaN(pid)) {
|
||||
result.set(name, pid);
|
||||
}
|
||||
if (!line.includes(PANE_LIST_SEP)) continue;
|
||||
const fields = line.split(PANE_LIST_SEP);
|
||||
const sessionName = fields[0];
|
||||
const pid = parseInt(fields[1] ?? '', 10);
|
||||
if (!sessionName || Number.isNaN(pid)) continue;
|
||||
const deadFlag = fields[2];
|
||||
rows.push({
|
||||
sessionName,
|
||||
pid,
|
||||
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
|
||||
exitStatus: paneField(fields, 3),
|
||||
exitSignal: paneField(fields, 4),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
|
||||
* Exported for unit testing. Returns one entry per session with a known answer;
|
||||
* a session absent from the map is UNKNOWN, which must never render as alive.
|
||||
*
|
||||
* Two rules make a positive answer trustworthy:
|
||||
*
|
||||
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
|
||||
* creates one pane per session and `isPaneDead()` reads one pane, so a session
|
||||
* the user has split by hand has no single "the agent" to report on, and
|
||||
* guessing which of its panes speaks for the session could report a live
|
||||
* session as exited.
|
||||
*
|
||||
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
|
||||
* that did not answer, not a live pane.
|
||||
*
|
||||
* `status` and `signal` stay absent when tmux did not report them. Measured on
|
||||
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
|
||||
* 0 would turn an unexplained death into a clean exit.
|
||||
*/
|
||||
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
|
||||
const panesPerSession = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
|
||||
}
|
||||
const exits = new Map<string, PaneExitObservation>();
|
||||
for (const row of rows) {
|
||||
if (panesPerSession.get(row.sessionName) !== 1) continue;
|
||||
if (row.dead !== true) continue;
|
||||
exits.set(row.sessionName, {
|
||||
panePid: row.pid,
|
||||
exit: {
|
||||
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
|
||||
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
|
||||
at: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
return exits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
|
||||
* for unit testing.
|
||||
*
|
||||
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
|
||||
* remote session's local pane holds the ssh client, a docker case's holds a
|
||||
* `docker exec` into the container's own tmux, and a record rebuilt from the
|
||||
* socket carries no provenance at all, so the session end forces all three to
|
||||
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
|
||||
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
|
||||
* the answer.
|
||||
*
|
||||
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
|
||||
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
|
||||
* with nothing to observe and picks the read straight back up as soon as one
|
||||
* local session exists.
|
||||
*/
|
||||
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
|
||||
for (const session of sessions) {
|
||||
if (session.remote) continue;
|
||||
if (session.docker) continue;
|
||||
if (session.discovered === true) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1527,6 +1663,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||
private lastPaneCount: Map<string, number> = new Map();
|
||||
/**
|
||||
* muxName → the exited agent the pane-exit watcher last observed
|
||||
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
|
||||
* entry goes the moment tmux stops reporting the pane dead, and the map is
|
||||
* empty until the first read runs. The manager reports what tmux says and
|
||||
* nothing more: the scoping that hides this for remote and docker sessions
|
||||
* lives on `Session`, because the remote-reconnect watcher above needs the
|
||||
* raw pane reading.
|
||||
*/
|
||||
private paneExits: Map<string, PaneExitObservation> = new Map();
|
||||
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
|
||||
private paneExitInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
|
||||
* poll interval of 2000 ms, so without this a slow read overlaps the next two
|
||||
* and the older one can resolve last and win.
|
||||
*/
|
||||
private paneExitReadInFlight = false;
|
||||
/**
|
||||
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
|
||||
* a clear carries the older generation and is discarded rather than writing
|
||||
* the death back over the pane that has just replaced it.
|
||||
*/
|
||||
private paneExitGeneration = 0;
|
||||
|
||||
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
||||
/** Periodic watcher that re-establishes dropped remote sessions. */
|
||||
@@ -2297,6 +2457,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
);
|
||||
// Wait for the respawned process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
// The pane now runs a fresh command, so whatever the last read observed of
|
||||
// the old one is history. Clearing it here rather than waiting for the next
|
||||
// poll also invalidates any read already in flight, which would otherwise
|
||||
// write the old death back over the pane that just replaced it.
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
return pid;
|
||||
@@ -2508,6 +2673,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
@@ -2618,6 +2784,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
@@ -2671,7 +2838,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
active = parsePaneList(output);
|
||||
const rows = parsePaneRows(output);
|
||||
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
|
||||
// The same read answers both questions, so recovery starts with a pane-exit
|
||||
// reading rather than waiting for the first stats tick — which may never
|
||||
// come, since the collector only starts when boot found a live session.
|
||||
if (rows.length > 0) {
|
||||
this.applyPaneExits(derivePaneExits(rows, Date.now()));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||||
active = new Map();
|
||||
@@ -2686,6 +2860,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearPaneExit(session.muxName);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionDied', { sessionId });
|
||||
}
|
||||
@@ -2722,6 +2897,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: `Restored: ${sessionName}`,
|
||||
// Every field above except the name and the pid is a guess: this record
|
||||
// was rebuilt from the socket because Codeman's own bookkeeping did not
|
||||
// have it. The synthetic id also cannot find the session's state.json
|
||||
// entry, so a remote or docker session rediscovered this way arrives
|
||||
// looking local. Consumers that would be wrong about such a session
|
||||
// read this flag and fail closed — see `Session.paneExitApplies`.
|
||||
discovered: true,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
knownMuxNames.add(sessionName);
|
||||
@@ -2882,6 +3064,172 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last pane read saw of this tmux session's agent. `undefined` is
|
||||
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
|
||||
* that is running, a tmux session that no longer exists, a probe that failed,
|
||||
* and every poll that has not run yet. See {@link PaneExit}.
|
||||
*/
|
||||
getPaneExit(muxName: string): PaneExit | undefined {
|
||||
return this.paneExits.get(muxName)?.exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
|
||||
* `tmux list-panes -a` answers for every session at once, which is why this
|
||||
* polls rather than probing per session.
|
||||
*
|
||||
* A failed or empty probe leaves the previous answers ALONE rather than
|
||||
* clearing them, because the two cannot be told apart: the command ends in
|
||||
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
|
||||
* arrive as empty output. Treating that as "tmux did not answer" is the
|
||||
* conservative reading — clearing on it would turn a transient failure into a
|
||||
* silent retraction of a death Codeman had already observed, and the cost of
|
||||
* being wrong the other way is one stale entry for a socket that no longer
|
||||
* has the pane. A NON-empty read is different: `list-panes -a` lists
|
||||
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
|
||||
* prunes against it.
|
||||
*
|
||||
* Two guards keep a slow read from undoing a fast one. A read already in
|
||||
* flight suppresses the next poll, and a read that started before a
|
||||
* {@link clearPaneExit} is discarded when it lands.
|
||||
*
|
||||
* A third guard skips the read entirely while no session on this manager
|
||||
* could produce an answer ({@link hasObservablePaneSession}). Skipping
|
||||
* retracts nothing, for the same reason a failed read does not: the map
|
||||
* still holds what the last real read saw, and every path that puts a new
|
||||
* command in a pane calls {@link clearPaneExit} itself.
|
||||
*/
|
||||
async refreshPaneExits(now: number = Date.now()): Promise<void> {
|
||||
// Nothing on this socket could answer, so do not read tmux to find that
|
||||
// out. See `hasObservablePaneSession`: the watcher above still ticks.
|
||||
if (!hasObservablePaneSession(this.sessions.values())) return;
|
||||
if (this.paneExitReadInFlight) return;
|
||||
|
||||
const generation = this.paneExitGeneration;
|
||||
this.paneExitReadInFlight = true;
|
||||
let rows: PaneRow[];
|
||||
try {
|
||||
rows = await this.readPaneRows();
|
||||
} finally {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
if (rows.length === 0) return;
|
||||
// A pane was respawned or killed while this read was out, so what it saw is
|
||||
// already history. Dropping it is what stops a freshly respawned pane from
|
||||
// being republished as exited.
|
||||
if (generation !== this.paneExitGeneration) return;
|
||||
|
||||
this.applyPaneExits(derivePaneExits(rows, now));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
|
||||
* touches tmux, which is what lets a test subclass drive the guards in
|
||||
* {@link refreshPaneExits} — the in-flight suppression, the generation
|
||||
* check, the empty-read retraction rule and the read gate — against rows it
|
||||
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
|
||||
* test can reach is a guard that can be deleted without anything failing.
|
||||
*
|
||||
* A failed read answers with NO rows, which the caller treats as "tmux did
|
||||
* not answer" and which therefore retracts nothing.
|
||||
*/
|
||||
protected async readPaneRows(): Promise<PaneRow[]> {
|
||||
// The test-mode gate lives HERE rather than at the top of the tick, so that
|
||||
// what tests cannot do is spawn a process, not exercise the bookkeeping.
|
||||
if (IS_TEST_MODE) return [];
|
||||
try {
|
||||
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
|
||||
// exec freezes the port while the process stays alive (see the
|
||||
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
|
||||
// stay synchronous because each is answering one request right then.
|
||||
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
return parsePaneRows(stdout.trim());
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to read pane exit state:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one authoritative observation into {@link paneExits}. Split out from
|
||||
* the tmux call so the merge rules are unit-testable.
|
||||
*
|
||||
* `observed` comes from a read of EVERY pane on the socket, so a session
|
||||
* missing from it has no exit to report and its entry goes. That is what
|
||||
* keeps the map from growing without bound as tmux sessions come and go
|
||||
* outside `killSession()`. Only the caller may decide a read is authoritative:
|
||||
* a failed or empty one never reaches here.
|
||||
*
|
||||
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
|
||||
* says when the agent was found gone rather than when the last poll ran. A
|
||||
* changed status, a changed signal, or a different pane pid all start a new
|
||||
* observation — the pid is what catches a second command in the same pane
|
||||
* that happened to exit the same way.
|
||||
*/
|
||||
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
|
||||
for (const muxName of [...this.paneExits.keys()]) {
|
||||
if (!observed.has(muxName)) this.paneExits.delete(muxName);
|
||||
}
|
||||
for (const [muxName, next] of observed) {
|
||||
const prev = this.paneExits.get(muxName);
|
||||
const sameExit =
|
||||
prev !== undefined &&
|
||||
prev.panePid === next.panePid &&
|
||||
prev.exit.status === next.exit.status &&
|
||||
prev.exit.signal === next.exit.signal;
|
||||
this.paneExits.set(muxName, sameExit ? prev : next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget a session's exit observation, e.g. once its pane has been respawned.
|
||||
* Also invalidates any read already in flight, so the answer this retracts
|
||||
* cannot be written back a moment later.
|
||||
*/
|
||||
clearPaneExit(muxName: string): void {
|
||||
this.paneExits.delete(muxName);
|
||||
this.paneExitGeneration++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for exited agents, on the manager's own interval.
|
||||
*
|
||||
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
|
||||
* when the browser opens the Monitor panel and DISARMED when it closes it
|
||||
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
|
||||
* recovered — so riding it would leave a session created on a freshly booted
|
||||
* server reporting nothing at all, and would let one browser turn exit
|
||||
* detection off for every other. Started unconditionally, like the mouse-mode
|
||||
* sync and the remote-reconnect watcher below.
|
||||
*
|
||||
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
|
||||
* SSE event, and the field reaches the browser on `session:updated`.
|
||||
*/
|
||||
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
|
||||
if (this.paneExitInterval) {
|
||||
clearInterval(this.paneExitInterval);
|
||||
}
|
||||
this.paneExitInterval = setInterval(() => {
|
||||
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
|
||||
// spawn a process and it refuses under test, so a test can drive this
|
||||
// whole loop with fake timers instead of being locked out of it.
|
||||
void this.refreshPaneExits()
|
||||
.then(() => this.emit('paneExitsUpdated'))
|
||||
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopPaneExitWatcher(): void {
|
||||
if (this.paneExitInterval) {
|
||||
clearInterval(this.paneExitInterval);
|
||||
this.paneExitInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
@@ -3101,6 +3449,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
this.stopPaneExitWatcher();
|
||||
this.paneExits.clear();
|
||||
this.stopMouseModeSync();
|
||||
this.stopRemoteReconnectWatcher();
|
||||
this.reconnectState.clear();
|
||||
|
||||
@@ -625,6 +625,54 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
||||
launchModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
|
||||
*
|
||||
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
|
||||
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
|
||||
* process Codeman records as the session's pid. No PTY exit handler runs, so
|
||||
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
|
||||
*
|
||||
* The field is TRI-STATE, and the third state is the absence of the field:
|
||||
* `undefined` means Codeman does not know, and it must never be rendered as
|
||||
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
|
||||
* SSH session (the local pane holds the ssh client, whose death means transport
|
||||
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
|
||||
* into the container's own tmux).
|
||||
*
|
||||
* `status` and `signal` are independently optional because tmux may know that
|
||||
* the pane died without reporting how. Measured on tmux 3.2a: a SIGKILLed pane
|
||||
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
|
||||
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
|
||||
* absent `status` means "the exit code is unknown", never "the exit code is 0".
|
||||
*
|
||||
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
|
||||
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
|
||||
* the absent status IS how a signal death presents, so absent-stays-absent is
|
||||
* the only thing keeping a future clean-exit sweep away from crashed agents:
|
||||
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
|
||||
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
|
||||
* types allow it, the label still renders, and the damage shows up only once
|
||||
* the sweep lands. The rule is enforced in `derivePaneExits()`
|
||||
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
|
||||
*/
|
||||
export interface PaneExit {
|
||||
/**
|
||||
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
|
||||
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
|
||||
* giving this a default anywhere.
|
||||
*/
|
||||
status?: number;
|
||||
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
|
||||
signal?: number;
|
||||
/**
|
||||
* Wall-clock ms when THIS server process first observed the pane dead. It is
|
||||
* not when the agent exited, which nothing records, and a restart that finds
|
||||
* the pane still dead respawns it rather than re-timing the old exit.
|
||||
*/
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -780,6 +828,21 @@ export interface SessionState {
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
*/
|
||||
respawnBlocked?: boolean;
|
||||
/**
|
||||
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
|
||||
* See {@link PaneExit} for the tri-state rule and for which session shapes
|
||||
* leave it absent. `status` and `pid` are deliberately untouched by it: the
|
||||
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
|
||||
* browser re-attach and launch a fresh CLI.
|
||||
*
|
||||
* Persisted so a reboot restore can tell a session whose agent exited from one
|
||||
* that was merely idle when the power went. `reboot-restore.ts` reads the
|
||||
* persisted record and never builds a `Session`, so the record is the only
|
||||
* place that survives the reboot to carry it. Nothing reads it there YET:
|
||||
* making the restore refuse such a session is a behavior change, and it
|
||||
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
|
||||
*/
|
||||
paneExit?: PaneExit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
-1
@@ -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 = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5279,6 +5338,7 @@ class CodemanApp {
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip>${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
|
||||
@@ -694,6 +694,22 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The exited-agent mute, a third time, for the phone (Ark0N/Codeman#446).
|
||||
The rule above enlarges the working dot and gives it a green glow with
|
||||
!important, and `status` deliberately stays `busy` for a pane whose agent
|
||||
died mid-turn — so without this a phone renders a 9px grey dot still
|
||||
wearing the green halo, beside a badge reading "exited". Measured; the
|
||||
desktop rules cannot reach it, since they declare no box-shadow and lose
|
||||
to !important anyway. !important here for the reason the glow needs it:
|
||||
the skin block in styles.css outranks any plain class rule in this file.
|
||||
⚠ The alert classes and the error state are excluded exactly as they are
|
||||
on desktop — a colour that means "this needs you" outranks "it exited". */
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
|
||||
background: var(--text-muted) !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Truncate tab names more aggressively on mobile */
|
||||
.session-tab .tab-name {
|
||||
max-width: 50px;
|
||||
|
||||
@@ -2433,6 +2433,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* The agent inside the pane has exited (Ark0N/Codeman#446). Unlike the badge
|
||||
above this one carries text the server computed, so it is added and removed
|
||||
by the renderer rather than toggled by a class. */
|
||||
.session-tab .tab-exited-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 4px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
background: rgba(248, 113, 113, 0.16);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* An exited agent quiets the status dot, which otherwise reports `status` and
|
||||
so stays green (or pulses, for a session the exit caught mid-turn) beside a
|
||||
badge saying the agent is gone. `status` itself is deliberately untouched —
|
||||
`error` is the PTY-exit breaker's value and makes the browser offer a restart
|
||||
— so this is a rendering rule only, and it matches `.tab-status.ended`.
|
||||
|
||||
⚠ Three states are excluded BY HAND rather than by cascade, the same way the
|
||||
rich-rail dot rules below do it: a dot turning red or yellow because a
|
||||
session is blocked on a human outranks "the agent exited", and this selector
|
||||
is specific enough (0,5,0) to have beaten those (0,3,0) rules otherwise. The
|
||||
third is `.tab-status.error` (0,2,0), which is the PTY-exit breaker's state
|
||||
and the one the browser answers with a "restart it?" confirm — the same
|
||||
argument that protects the two alert classes, and it reaches the dot rather
|
||||
than the tab, which is why the `:not()` sits on `.tab-status` here and on
|
||||
`.session-tab` there. The spinner ring is a pseudo-element the skin blocks
|
||||
cannot reach, so it needs hiding explicitly rather than by unsetting the
|
||||
animation. */
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
animation: none;
|
||||
}
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Tab action icons: active tab only =================================
|
||||
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
|
||||
no width of its own while the children keep the width:0 collapse above).
|
||||
@@ -18477,6 +18522,26 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* The exited-agent mute, again, for the rich rail (Ark0N/Codeman#446).
|
||||
The strip's rule is (0,5,0) and the three state rules above are (0,9,1), so
|
||||
on this rail an exited session kept a full green dot and the working halo
|
||||
beside a badge reading "exited" — measured, the contradiction the mute
|
||||
exists to remove. This twin matches their (0,9,1) exactly and therefore MUST
|
||||
stay below them in source order; moving it above silently restores the green
|
||||
dot. It also clears the halo, which is a box-shadow the strip's rule never
|
||||
had to think about.
|
||||
⚠ The alert exclusions are repeated by hand for the same reason they are on
|
||||
the rules above: a dot turning red or yellow because a session is blocked on
|
||||
a human outranks "the agent exited". */
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
|
||||
.tab-rail
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle)
|
||||
.tab-status:not(.error) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Card accents: the same three colours as every other session surface, and the
|
||||
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
|
||||
on top of this for the sessions that are genuinely blocked on a human; these
|
||||
|
||||
@@ -1583,6 +1583,12 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: session.mode,
|
||||
});
|
||||
// Persist, not just broadcast. Starting a command in the pane changes
|
||||
// `pid` and retracts any `paneExit` (Ark0N/Codeman#446), and the pane-exit
|
||||
// watcher cannot write that retraction to disk for us: its next tick finds
|
||||
// the in-memory field already cleared, reports no change and persists
|
||||
// nothing, so `state.json` would keep saying the agent had exited.
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
@@ -1612,6 +1618,9 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
// Persist for the same reason /interactive does: a started pane retracts
|
||||
// `paneExit`, and the watcher's next tick cannot write that retraction.
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return {};
|
||||
|
||||
@@ -463,6 +463,11 @@ export class WebServer extends EventEmitter {
|
||||
this.mux.on('statsUpdated', (sessions) => {
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
// Ark0N/Codeman#446 — a pane read finished. Internal only: the field reaches
|
||||
// the browser on `session:updated`, and no SSE event was added for it.
|
||||
this.mux.on('paneExitsUpdated', () => {
|
||||
this.applyPaneExits();
|
||||
});
|
||||
|
||||
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
|
||||
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
|
||||
@@ -2455,6 +2460,36 @@ export class WebServer extends EventEmitter {
|
||||
this.sse.broadcastSessionStateDebounced(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest pane readings into the sessions they belong to
|
||||
* (Ark0N/Codeman#446). A reading that changes a session's answer persists the
|
||||
* record and pushes a `session:updated`, which is how the tab learns; a read
|
||||
* that repeats what the last one said costs nothing.
|
||||
*
|
||||
* The answer is pulled per session from the mux rather than taken off a
|
||||
* broadcast payload. The mux reports the RAW pane reading, which for a remote
|
||||
* or docker session is the death of an ssh client or a `docker exec` rather
|
||||
* than of the agent, so it must not travel to a browser at all;
|
||||
* `Session.setPaneExit()` is where that scoping is applied.
|
||||
*
|
||||
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
|
||||
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
|
||||
* what makes the browser re-attach and launch a fresh CLI.
|
||||
*/
|
||||
private applyPaneExits(): void {
|
||||
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
|
||||
if (!getPaneExit) return;
|
||||
for (const session of this.sessions.values()) {
|
||||
const muxName = session.muxName;
|
||||
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
|
||||
// for such a session anyway.
|
||||
if (!muxName) continue;
|
||||
if (!session.setPaneExit(getPaneExit(muxName))) continue;
|
||||
this.persistSessionState(session);
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Web Push ==========
|
||||
|
||||
/** Map SSE event names to push notification payloads */
|
||||
@@ -3336,6 +3371,15 @@ export class WebServer extends EventEmitter {
|
||||
// the conversation the CLI was on when the server stopped, which is
|
||||
// what a re-attach must point the viewer at instead of the launch id.
|
||||
claudeSessionChain: savedState?.claudeSessionChain,
|
||||
// What the previous run last observed of this pane's agent. Carried
|
||||
// over so the first persist after boot does not blank a record that
|
||||
// says the agent exited; the attach below drops it, and the
|
||||
// pane-exit watcher's own tick replaces it with a first-hand
|
||||
// reading (not the stats collector — see `startPaneExitWatcher`).
|
||||
paneExit: savedState?.paneExit,
|
||||
// A record rebuilt from the socket has no provenance, so its
|
||||
// apparent locality is a guess (see `MuxSession.discovered`).
|
||||
discoveredMuxSession: muxSession.discovered,
|
||||
// The pane's last output, previous run's value. Without it every
|
||||
// restart restamped all sessions "now" (constructor + the attach
|
||||
// repaint within the same second), flattening the home screens'
|
||||
@@ -3545,6 +3589,15 @@ export class WebServer extends EventEmitter {
|
||||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||
}
|
||||
|
||||
// Ark0N/Codeman#446 — poll every pane for an exited agent. Always start,
|
||||
// even with no sessions, for the same reason as the two watchers around
|
||||
// it: sessions arrive later. Deliberately NOT folded into the stats
|
||||
// collector above, which the browser arms and disarms with the Monitor
|
||||
// panel and which boot skips entirely when nothing was recovered.
|
||||
if ('startPaneExitWatcher' in this.mux) {
|
||||
(this.mux as { startPaneExitWatcher: (ms?: number) => void }).startPaneExitWatcher();
|
||||
}
|
||||
|
||||
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
|
||||
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
|
||||
// each tick. Start even with no sessions — remote sessions may arrive later.
|
||||
|
||||
@@ -1371,6 +1371,19 @@ describe('session-routes', () => {
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
// Ark0N/Codeman#446: starting a command in the pane retracts `paneExit`, and
|
||||
// the pane-exit watcher cannot write that retraction for us — its next tick
|
||||
// finds the field already cleared, reports no change and persists nothing.
|
||||
// Broadcasting without persisting leaves state.json saying the agent exited.
|
||||
it('persists the session, not just broadcasts it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
harness.ctx._session.isBusy.mockReturnValue(true);
|
||||
const res = await harness.app.inject({
|
||||
@@ -1447,6 +1460,16 @@ describe('session-routes', () => {
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
// Same reason as /interactive above (Ark0N/Codeman#446).
|
||||
it('persists the session, not just broadcasts it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/shell`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
harness.ctx._session.isBusy.mockReturnValue(true);
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @fileoverview The exited-agent badge on a session tab (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. These cover the three
|
||||
* things the browser owns: turning that field into a label, getting the label
|
||||
* onto and off a tab, and what colour the tab's status dot ends up once the
|
||||
* exit, the alert rules and the rich rail's own rules have all had a say.
|
||||
*
|
||||
* The incremental render path is the only one a live session ever reaches.
|
||||
* Going from live to exited adds and removes no tab, so the full rebuild never
|
||||
* runs for it, which is why `applyPaneExitBadge()` is a named function rather
|
||||
* than a block inside the render loop.
|
||||
*
|
||||
* Port: N/A
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('the exited-agent tab label', () => {
|
||||
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const load = <T>(name: string) => {
|
||||
const source = appJs.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))?.[0];
|
||||
if (!source) throw new Error(`${name} not found in app.js`);
|
||||
return new Function(`${source}\nreturn ${name};`)() as T;
|
||||
};
|
||||
const paneExitLabel = load<(p: unknown) => string>('paneExitLabel');
|
||||
|
||||
it('renders nothing for an unknown answer, which must never read as alive', () => {
|
||||
expect(paneExitLabel(undefined)).toBe('');
|
||||
expect(paneExitLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
it('names the exit code', () => {
|
||||
expect(paneExitLabel({ status: 137, at: 1 })).toBe('exited (137)');
|
||||
});
|
||||
|
||||
it('shows a clean exit as 0 rather than hiding it', () => {
|
||||
expect(paneExitLabel({ status: 0, at: 1 })).toBe('exited (0)');
|
||||
});
|
||||
|
||||
it('names a signal death, which the maintainer wants kept on screen', () => {
|
||||
expect(paneExitLabel({ signal: 9, at: 1 })).toBe('exited (signal 9)');
|
||||
});
|
||||
|
||||
it('says only "exited" when tmux knew the pane died but not how', () => {
|
||||
// Measured on tmux 3.2a: a SIGKILLed pane reports neither status nor signal.
|
||||
// Showing that as "exited (0)" would make an unexplained death look clean.
|
||||
expect(paneExitLabel({ at: 1 })).toBe('exited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the exited-agent badge in a tab', () => {
|
||||
// The incremental render path is the only one a live session reaches: going
|
||||
// from live to exited adds and removes no tab, so the full rebuild never runs
|
||||
// for it. These drive that path's DOM work against a real tab element.
|
||||
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const source = [
|
||||
appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0],
|
||||
appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0],
|
||||
].join('\n');
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const applyPaneExitBadge = new Function('document', `${source}\nreturn applyPaneExitBadge;`)(dom.window.document) as (
|
||||
tab: unknown,
|
||||
paneExit: unknown
|
||||
) => void;
|
||||
|
||||
const makeTab = () => {
|
||||
const tab = dom.window.document.createElement('div');
|
||||
tab.className = 'session-tab';
|
||||
tab.innerHTML = '<span class="tab-name">w1-case</span>';
|
||||
return tab;
|
||||
};
|
||||
const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) =>
|
||||
tab.querySelector('.tab-exited-badge');
|
||||
|
||||
it('draws no badge while the answer is unknown', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(badge(tab)).toBeNull();
|
||||
});
|
||||
|
||||
it('adds the badge after the name once the agent exits', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 137, at: 1 });
|
||||
expect(badge(tab)?.textContent).toBe('exited (137)');
|
||||
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
|
||||
});
|
||||
|
||||
it('marks the badge data-i18n-skip, like the other generated status text', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the text in place rather than stacking a second badge', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
const first = badge(tab);
|
||||
applyPaneExitBadge(tab, { status: 137, at: 2 });
|
||||
expect(tab.querySelectorAll('.tab-exited-badge')).toHaveLength(1);
|
||||
expect(badge(tab)).toBe(first);
|
||||
expect(badge(tab)?.textContent).toBe('exited (137)');
|
||||
});
|
||||
|
||||
it('marks the tab so the status dot can be quieted', () => {
|
||||
// The dot renders from `status`, which stays `idle` or `busy` for an exited
|
||||
// pane by design, so the tab carries the exit as a class and CSS does the
|
||||
// rest. Without it a green or pulsing dot sits beside the badge.
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
expect(tab.classList.contains('tab-agent-exited')).toBe(true);
|
||||
});
|
||||
|
||||
it('unmarks the tab when the pane comes back', () => {
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the badge when the pane comes back', () => {
|
||||
// The retraction half: a respawned pane must not keep reading "exited".
|
||||
const tab = makeTab();
|
||||
applyPaneExitBadge(tab, { status: 0, at: 1 });
|
||||
applyPaneExitBadge(tab, undefined);
|
||||
expect(badge(tab)).toBeNull();
|
||||
});
|
||||
|
||||
it('is what the incremental render path calls', () => {
|
||||
expect(appJs).toContain('applyPaneExitBadge(tab, session.paneExit)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('what colour the status dot ends up', () => {
|
||||
/*
|
||||
* The dot renders from `status`, which stays `idle` or `busy` for an exited
|
||||
* pane, so the mute is a CSS rule keyed on the `tab-agent-exited` class. It
|
||||
* competes with two other families of rule over the same dot, and this tree
|
||||
* has lost that competition before: the alert rules and the rich-rail state
|
||||
* rules already exclude each other by hand rather than by cascade.
|
||||
*
|
||||
* So the cascade is resolved rather than asserted from selector text. Every
|
||||
* rule in styles.css that paints `.tab-status` goes into a real document and
|
||||
* a real engine answers, which is what makes a rule moved up the file or a
|
||||
* selector given one more class fail here.
|
||||
*
|
||||
* ⚠ In styles.css the rules inside an at-rule are skipped, so the desktop
|
||||
* cases describe a wide viewport with motion allowed. mobile.css is loaded
|
||||
* separately for the phone cases, and there its @media blocks are FLATTENED
|
||||
* rather than skipped, because that file is phone-and-tablet-only and its
|
||||
* whole content sits inside them. jsdom reports a custom property
|
||||
* unresolved, so the expected values are the `var(--x)` tokens the
|
||||
* stylesheets write.
|
||||
*/
|
||||
const readRules = (file: string, flattenMedia: boolean): string[] => {
|
||||
const out: string[] = [];
|
||||
postcss.parse(readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8')).walkRules((rule) => {
|
||||
if (!rule.selector.includes('.tab-status')) return;
|
||||
const parents: string[] = [];
|
||||
let insideAtRule = false;
|
||||
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
|
||||
if (p.type === 'rule') parents.unshift(p.selector);
|
||||
else insideAtRule = true;
|
||||
}
|
||||
if (insideAtRule && !flattenMedia) return;
|
||||
const decls: string[] = [];
|
||||
rule.each((node) => {
|
||||
if (node.type === 'decl') decls.push(`${node.prop}: ${node.value}${node.important ? ' !important' : ''};`);
|
||||
});
|
||||
if (decls.length === 0) return;
|
||||
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
|
||||
out.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
const dotRules = readRules('styles.css', false);
|
||||
// index.html loads mobile.css after styles.css, so it goes last here too.
|
||||
const phoneRules = [...dotRules, ...readRules('mobile.css', true)];
|
||||
|
||||
/** Paint the dot of one tab and read back what the cascade decided. */
|
||||
const dot = (opts: { tab: string; dotState?: string; rail?: boolean; phone?: boolean }) => {
|
||||
const railAttrs = opts.rail ? ` data-tab-orientation="vertical" data-tab-rail-detail="rich"` : '';
|
||||
const container = opts.rail ? 'tab-rail' : 'session-tabs';
|
||||
const rules = opts.phone ? phoneRules : dotRules;
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html${railAttrs}><head><style>${rules.join('\n')}</style></head><body>` +
|
||||
`<div class="${container}"><div class="session-tab ${opts.tab}">` +
|
||||
`<span id="dot" class="tab-status ${opts.dotState ?? 'idle'}"></span></div></div></body></html>`
|
||||
);
|
||||
const style = dom.window.getComputedStyle(dom.window.document.getElementById('dot')!);
|
||||
return {
|
||||
background: style.background,
|
||||
opacity: style.opacity,
|
||||
boxShadow: style.boxShadow,
|
||||
animation: style.animation,
|
||||
};
|
||||
};
|
||||
|
||||
it('finds the rules it is meant to be resolving', () => {
|
||||
// A selector rename that emptied this list would make every case below pass
|
||||
// against a stylesheet with no rules in it.
|
||||
expect(dotRules.some((rule) => rule.includes('tab-agent-exited'))).toBe(true);
|
||||
expect(dotRules.some((rule) => rule.includes('tab-alert-action'))).toBe(true);
|
||||
});
|
||||
|
||||
it('mutes the dot of an exited session', () => {
|
||||
expect(dot({ tab: 'tab-agent-exited' })).toMatchObject({ background: 'var(--text-muted)', opacity: '0.5' });
|
||||
});
|
||||
|
||||
it('leaves a live session green', () => {
|
||||
expect(dot({ tab: '' }).background).toBe('var(--green)');
|
||||
});
|
||||
|
||||
it('keeps a pending permission dialog RED on an exited session', () => {
|
||||
// The one the maintainer asked for: the exit must not quiet an alert. A
|
||||
// board that says two things at once is a board people stop trusting, and
|
||||
// between "the agent is gone" and "this session is blocked on you", the
|
||||
// one that needs a human wins.
|
||||
expect(dot({ tab: 'tab-agent-exited tab-alert-action' }).background).toBe('var(--red)');
|
||||
});
|
||||
|
||||
it('keeps a pending idle alert YELLOW on an exited session', () => {
|
||||
expect(dot({ tab: 'tab-agent-exited tab-alert-idle' }).background).toBe('var(--yellow)');
|
||||
});
|
||||
|
||||
it('mutes a dot the exit caught mid-turn, and stops it pulsing', () => {
|
||||
// `.tab-status.busy` animates `pulse`, so muting the colour alone would
|
||||
// leave a grey dot breathing as if the agent were still working.
|
||||
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy' })).toMatchObject({
|
||||
background: 'var(--text-muted)',
|
||||
opacity: '0.5',
|
||||
animation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('mutes the dot on a rich tab rail too, halo included', () => {
|
||||
// The rail's own state rules are far more specific than the strip's mute
|
||||
// (measured: an exited session kept a full green dot AND the working halo),
|
||||
// so the mute carries a rail twin that must stay below them in source order.
|
||||
expect(dot({ tab: 'tab-agent-exited tab-state-working', dotState: 'busy', rail: true })).toMatchObject({
|
||||
background: 'var(--text-muted)',
|
||||
opacity: '0.5',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
expect(dot({ tab: 'tab-agent-exited tab-state-idle', rail: true }).background).toBe('var(--text-muted)');
|
||||
});
|
||||
|
||||
it('leaves an errored dot red, which is the state that offers a restart', () => {
|
||||
// `status: 'error'` is the PTY-exit breaker's value and the browser answers
|
||||
// it with a "restart it?" confirm, so it is a needs-you colour by the same
|
||||
// argument that protects the two alert classes. Reachable when a restart of
|
||||
// a dead pane keeps failing: the breaker trips while the pane stays dead.
|
||||
expect(dot({ tab: 'tab-agent-exited', dotState: 'error' }).background).toBe('var(--red)');
|
||||
});
|
||||
|
||||
it('mutes the dot on a phone, glow and all', () => {
|
||||
// mobile.css enlarges the working dot to 9px and gives it a green glow with
|
||||
// !important, and `status` stays `busy` for a pane whose agent died
|
||||
// mid-turn — so without a phone-side rule this renders a grey dot wearing a
|
||||
// green halo beside a badge reading "exited".
|
||||
expect(dot({ tab: 'tab-agent-exited', dotState: 'busy', phone: true })).toMatchObject({
|
||||
background: 'var(--text-muted)',
|
||||
boxShadow: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an alert red on a phone as well', () => {
|
||||
expect(dot({ tab: 'tab-agent-exited tab-alert-action', dotState: 'busy', phone: true }).background).toBe(
|
||||
'var(--red)'
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the phone rules it is meant to be resolving', () => {
|
||||
// Same self-guard as the desktop one: if mobile.css stopped contributing
|
||||
// rules, every phone case above would pass against the desktop cascade.
|
||||
expect(phoneRules.length).toBeGreaterThan(dotRules.length);
|
||||
});
|
||||
|
||||
it('still keeps an alert red on the rich tab rail', () => {
|
||||
expect(
|
||||
dot({ tab: 'tab-agent-exited tab-alert-action tab-state-working', dotState: 'busy', rail: true }).background
|
||||
).toBe('var(--red)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @fileoverview `SessionState.paneExit` — Codeman noticing that a pane's agent
|
||||
* has exited (Ark0N/Codeman#446).
|
||||
*
|
||||
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
|
||||
* CLI while tmux keeps the pane and the `tmux attach-session` process Codeman
|
||||
* records as the session's pid. No PTY exit handler runs, and the record used to
|
||||
* keep both its pid and `status: 'idle'`, so the board showed an exited session
|
||||
* as a live idle one.
|
||||
*
|
||||
* Four properties are pinned here, each because getting it wrong costs something
|
||||
* specific:
|
||||
*
|
||||
* 1. **The field is tri-state, and absence means UNKNOWN.** A direct-PTY
|
||||
* session owns no pane, a remote SSH session's local pane holds the ssh
|
||||
* client, and a docker case's local pane holds a `docker exec`. In all
|
||||
* three, a dead local pane is not the agent exiting.
|
||||
* 2. **`status` and `pid` are never touched.** `status: 'error'` belongs to the
|
||||
* PTY-exit circuit breaker and makes the browser offer a restart, and a null
|
||||
* `pid` is what makes the browser re-attach and launch a fresh CLI.
|
||||
* 3. **It reaches `toState()`**, which is both the `session:updated` payload
|
||||
* and what `state.json` persists.
|
||||
* 4. **It round-trips through the store**, because a reboot takes the tmux
|
||||
* server and the persisted record is the only thing left that can say the
|
||||
* agent was already gone.
|
||||
*
|
||||
* Port: 3187
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { StateStore } from '../src/state-store.js';
|
||||
import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../src/types.js';
|
||||
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||
import { hasObservablePaneSession } from '../src/tmux-manager.js';
|
||||
|
||||
const PORT = 3187;
|
||||
|
||||
const EXIT: PaneExit = { status: 0, at: 1_700_000_000_000 };
|
||||
|
||||
/** The two mux members `Session` reads when it decides whether the field applies. */
|
||||
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
|
||||
|
||||
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
|
||||
|
||||
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
|
||||
|
||||
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
|
||||
|
||||
/** A local, mux-backed session: the one shape the field applies to. */
|
||||
function localMuxSession(extra: Record<string, unknown> = {}) {
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: true,
|
||||
mux: stubMux(),
|
||||
muxSession: stubMuxSession(),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Session.setPaneExit scoping', () => {
|
||||
it('accepts an exit for a local mux-backed session', () => {
|
||||
const session = localMuxSession();
|
||||
expect(session.setPaneExit(EXIT)).toBe(true);
|
||||
expect(session.paneExit).toEqual(EXIT);
|
||||
});
|
||||
|
||||
it('stays unknown for a direct-PTY session, which owns no pane at all', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
|
||||
expect(session.setPaneExit(EXIT)).toBe(false);
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays unknown while the session has no mux session yet', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: true, mux: stubMux() });
|
||||
expect(session.setPaneExit(EXIT)).toBe(false);
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays unknown for a remote SSH session, whose pane holds the ssh client', () => {
|
||||
// A dead ssh client means a transport drop OR an exit, and telling those two
|
||||
// apart is the whole of PR #355. Publishing it as an agent exit would assert
|
||||
// the answer Codeman does not have.
|
||||
const session = localMuxSession({ remote });
|
||||
expect(session.setPaneExit(EXIT)).toBe(false);
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays unknown for a docker case, whose pane holds a docker exec', () => {
|
||||
const session = localMuxSession({ docker });
|
||||
expect(session.setPaneExit(EXIT)).toBe(false);
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a stored exit when a later tick reports nothing', () => {
|
||||
const session = localMuxSession();
|
||||
session.setPaneExit(EXIT);
|
||||
expect(session.setPaneExit(undefined)).toBe(true);
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports no change when a tick repeats the same observation', () => {
|
||||
// The caller persists and broadcasts on a true, and this tick runs every
|
||||
// 2000 ms for every session.
|
||||
const session = localMuxSession();
|
||||
session.setPaneExit(EXIT);
|
||||
expect(session.setPaneExit({ ...EXIT })).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a change when the exit status changes', () => {
|
||||
const session = localMuxSession();
|
||||
session.setPaneExit(EXIT);
|
||||
expect(session.setPaneExit({ status: 137, at: EXIT.at })).toBe(true);
|
||||
expect(session.paneExit).toEqual({ status: 137, at: EXIT.at });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the watcher's read gate agrees with the session's scoping", () => {
|
||||
// `hasObservablePaneSession()` decides whether a watcher tick execs tmux at
|
||||
// all, and `Session.paneExitApplies` decides whether the answer is kept. They
|
||||
// are two copies of one rule, and drift between them is silent: too narrow
|
||||
// and a session that could report an exit never gets read, too wide and every
|
||||
// tick pays for an answer the session throws away.
|
||||
const muxSession = (extra: Partial<MuxSession> = {}): MuxSession =>
|
||||
({
|
||||
sessionId: 'aaaa',
|
||||
muxName: 'codeman-aaaa',
|
||||
pid: 100,
|
||||
createdAt: 0,
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: true,
|
||||
...extra,
|
||||
}) as MuxSession;
|
||||
|
||||
const cases: { shape: string; mux: MuxSession; session: () => Session }[] = [
|
||||
{ shape: 'local', mux: muxSession(), session: () => localMuxSession() },
|
||||
{ shape: 'remote SSH', mux: muxSession({ remote }), session: () => localMuxSession({ remote }) },
|
||||
{ shape: 'docker', mux: muxSession({ docker }), session: () => localMuxSession({ docker }) },
|
||||
{
|
||||
shape: 'rebuilt from the socket',
|
||||
mux: muxSession({ discovered: true }),
|
||||
session: () => localMuxSession({ discoveredMuxSession: true }),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { shape, mux, session } of cases) {
|
||||
it(`agrees for a ${shape} session`, () => {
|
||||
const sessionKeepsIt = session().setPaneExit(EXIT);
|
||||
expect(hasObservablePaneSession([mux])).toBe(sessionKeepsIt);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Session.toState with an exited agent', () => {
|
||||
it('publishes the exit and leaves status and pid alone', () => {
|
||||
const session = localMuxSession();
|
||||
const before = session.toState();
|
||||
session.setPaneExit(EXIT);
|
||||
const after = session.toState();
|
||||
|
||||
expect(before.paneExit).toBeUndefined();
|
||||
expect(after.paneExit).toEqual(EXIT);
|
||||
expect(after.status).toBe(before.status);
|
||||
expect(after.pid).toBe(before.pid);
|
||||
// `status: 'error'` is the PTY-exit breaker's value; the browser answers it
|
||||
// with a "restart it?" confirm.
|
||||
expect(after.status).not.toBe('error');
|
||||
});
|
||||
|
||||
it('restores a persisted exit so the first persist after boot cannot blank it', () => {
|
||||
const session = localMuxSession({ paneExit: EXIT });
|
||||
expect(session.toState().paneExit).toEqual(EXIT);
|
||||
});
|
||||
|
||||
it('ignores a persisted exit for a session shape the field never applies to', () => {
|
||||
// The scoping is not only about live ticks: a record written before a
|
||||
// session was reconfigured must not resurrect an answer that cannot hold.
|
||||
// The constructor alone has to enforce it, before any tick runs.
|
||||
expect(new Session({ workingDir: '/tmp', mode: 'claude', useMux: false, paneExit: EXIT }).paneExit).toBeUndefined();
|
||||
expect(localMuxSession({ remote, paneExit: EXIT }).paneExit).toBeUndefined();
|
||||
expect(localMuxSession({ docker, paneExit: EXIT }).paneExit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a relaunch in the same pane forgetting the old exit', () => {
|
||||
/** A mux whose respawn succeeds, so `restartCli()` reaches its success path. */
|
||||
const respawningMux = () => {
|
||||
const cleared: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
respawnPane: async () => 4242,
|
||||
clearPaneExit: (muxName: string) => cleared.push(muxName),
|
||||
};
|
||||
return { mux: mux as unknown as TerminalMultiplexer, cleared };
|
||||
};
|
||||
|
||||
it('clears the exit when restartCli relaunches the CLI', async () => {
|
||||
// restartCli() is the custom-model endpoint switch. Its caller persists and
|
||||
// broadcasts straight afterwards, so an exit left in place here is written
|
||||
// back onto a session that is running again.
|
||||
const { mux, cleared } = respawningMux();
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: true,
|
||||
mux,
|
||||
muxSession: stubMuxSession(),
|
||||
paneExit: EXIT,
|
||||
});
|
||||
expect(session.paneExit).toEqual(EXIT);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
expect(session.toState().paneExit).toBeUndefined();
|
||||
// Both halves: the record and the mux layer's cache, the latter of which
|
||||
// also invalidates a pane read already in flight.
|
||||
expect(cleared).toEqual(['codeman-aaaa']);
|
||||
});
|
||||
|
||||
it('leaves the exit alone when the relaunch fails', async () => {
|
||||
// A failed respawn means the old command is still what the pane last ran.
|
||||
const { mux, cleared } = respawningMux();
|
||||
(mux as unknown as { respawnPane: () => Promise<null> }).respawnPane = async () => null;
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: true,
|
||||
mux,
|
||||
muxSession: stubMuxSession(),
|
||||
paneExit: EXIT,
|
||||
});
|
||||
|
||||
expect(await session.restartCli()).toBe(false);
|
||||
|
||||
expect(session.paneExit).toEqual(EXIT);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paneExit round trip through state.json', () => {
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
it('survives a write and a reload, which is what a reboot restore reads', () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
|
||||
const file = join(dir, 'state.json');
|
||||
const stored: SessionState = {
|
||||
...localMuxSession().toState(),
|
||||
paneExit: { status: 0, signal: undefined, at: 1_700_000_000_000 },
|
||||
};
|
||||
|
||||
const writer = new StateStore(file);
|
||||
writer.setSession(stored.id, stored);
|
||||
writer.saveNow();
|
||||
|
||||
const reader = new StateStore(file);
|
||||
expect(reader.getSession(stored.id)?.paneExit).toEqual({ at: 1_700_000_000_000, status: 0 });
|
||||
});
|
||||
|
||||
it('reads a record written before the field existed as unknown, needing no migration', () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-pane-exit-'));
|
||||
const file = join(dir, 'state.json');
|
||||
const legacy = localMuxSession().toState();
|
||||
delete legacy.paneExit;
|
||||
|
||||
const writer = new StateStore(file);
|
||||
writer.setSession(legacy.id, legacy);
|
||||
writer.saveNow();
|
||||
|
||||
expect(new StateStore(file).getSession(legacy.id)?.paneExit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pane read reaching the session record', () => {
|
||||
// Drives the real `paneExitsUpdated` wiring rather than asserting on source
|
||||
// text: a fake reading goes into the mux, the event fires, and the session
|
||||
// record is checked. This is the path findings about the watcher turn on.
|
||||
let server: WebServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.stop?.();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const build = () => {
|
||||
const web = new WebServer(PORT, false, true);
|
||||
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
|
||||
const session = localMuxSession();
|
||||
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
|
||||
return { web, mux, session };
|
||||
};
|
||||
|
||||
it('publishes an exit the mux reports for a local pane', () => {
|
||||
const { web, mux, session } = build();
|
||||
server = web;
|
||||
mux.getPaneExit = () => EXIT;
|
||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
||||
|
||||
expect(session.paneExit).toEqual(EXIT);
|
||||
expect(session.toState().paneExit).toEqual(EXIT);
|
||||
});
|
||||
|
||||
it('leaves status and pid alone while doing it', () => {
|
||||
const { web, mux, session } = build();
|
||||
server = web;
|
||||
const before = session.toState();
|
||||
mux.getPaneExit = () => EXIT;
|
||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
||||
|
||||
const after = session.toState();
|
||||
expect(after.status).toBe(before.status);
|
||||
expect(after.pid).toBe(before.pid);
|
||||
// `status: 'error'` is the PTY-exit breaker's value; it makes the browser
|
||||
// offer a restart. A null pid makes it launch a fresh CLI.
|
||||
expect(after.status).not.toBe('error');
|
||||
});
|
||||
|
||||
it('retracts the exit once the mux reports the pane is back', () => {
|
||||
const { web, mux, session } = build();
|
||||
server = web;
|
||||
mux.getPaneExit = () => EXIT;
|
||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
||||
mux.getPaneExit = () => undefined;
|
||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
||||
|
||||
expect(session.paneExit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('publishes nothing for a session the field does not apply to', () => {
|
||||
const { web, mux } = build();
|
||||
server = web;
|
||||
const remoteSession = localMuxSession({ remote });
|
||||
(web as unknown as { sessions: Map<string, Session> }).sessions.set(remoteSession.id, remoteSession);
|
||||
mux.getPaneExit = () => EXIT;
|
||||
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
|
||||
|
||||
expect(remoteSession.paneExit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+345
-17
@@ -14,10 +14,14 @@ import {
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
parsePaneRows,
|
||||
derivePaneExits,
|
||||
hasObservablePaneSession,
|
||||
type PaneRow,
|
||||
resolveActivePaneTarget,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { execSync, exec } from 'node:child_process';
|
||||
import type { MuxSession } from '../src/mux-interface.js';
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests (mocked)
|
||||
@@ -910,41 +914,44 @@ describe('TmuxManager (unit)', () => {
|
||||
// exec without TTY). See PR #71.
|
||||
// ============================================================================
|
||||
|
||||
describe('parsePaneList', () => {
|
||||
describe('parsePaneRows', () => {
|
||||
/** Pull the name → pid map reconciliation builds, so these cases read as they used to. */
|
||||
const pids = (output: string) => new Map(parsePaneRows(output).map((row) => [row.sessionName, row.pid]));
|
||||
|
||||
it('parses well-formed output into name → pid', () => {
|
||||
const out = 'codeman-aaaa|1234\ncodeman-bbbb|5678\nclaudeman-cccc|9999';
|
||||
const result = parsePaneList(out);
|
||||
const result = pids(out);
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('codeman-aaaa')).toBe(1234);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
expect(result.get('claudeman-cccc')).toBe(9999);
|
||||
});
|
||||
|
||||
it('returns an empty map for empty output', () => {
|
||||
expect(parsePaneList('').size).toBe(0);
|
||||
it('returns no rows for empty output', () => {
|
||||
expect(parsePaneRows('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips blank lines', () => {
|
||||
const result = parsePaneList('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
|
||||
const result = pids('\ncodeman-aaaa|100\n\n\ncodeman-bbbb|200\n');
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('codeman-aaaa')).toBe(100);
|
||||
expect(result.get('codeman-bbbb')).toBe(200);
|
||||
});
|
||||
|
||||
it('skips lines without the separator', () => {
|
||||
const result = parsePaneList('codeman-aaaa 1234\ncodeman-bbbb|5678');
|
||||
const result = pids('codeman-aaaa 1234\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
|
||||
it('skips lines with a non-numeric pid', () => {
|
||||
const result = parsePaneList('codeman-aaaa|notapid\ncodeman-bbbb|5678');
|
||||
const result = pids('codeman-aaaa|notapid\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
|
||||
it('skips lines with an empty session name', () => {
|
||||
const result = parsePaneList('|1234\ncodeman-bbbb|5678');
|
||||
const result = pids('|1234\ncodeman-bbbb|5678');
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('codeman-bbbb')).toBe(5678);
|
||||
});
|
||||
@@ -955,15 +962,336 @@ describe('parsePaneList', () => {
|
||||
// tab byte. With the '|' separator, such literals must not be silently
|
||||
// treated as a delimiter — the line is discarded because there is no '|'.
|
||||
const literalBackslashT = 'codeman-aaaa\\t1234';
|
||||
const result = parsePaneList(literalBackslashT);
|
||||
expect(result.size).toBe(0);
|
||||
expect(parsePaneRows(literalBackslashT)).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits on the first separator only', () => {
|
||||
// Numeric trailing junk after the pid is tolerated by parseInt — proves
|
||||
// that splitting on the first '|' leaves the pid extractable even if a
|
||||
// future tmux ever appended extra fields.
|
||||
const result = parsePaneList('codeman-aaaa|1234|extra-field');
|
||||
expect(result.get('codeman-aaaa')).toBe(1234);
|
||||
it('keeps a row whose pane_dead fields are missing, and calls its deadness unknown', () => {
|
||||
// A tmux old enough to have shipped the previous two-field format, or one
|
||||
// that dropped the trailing fields, must still yield its pid.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234');
|
||||
expect(row.pid).toBe(1234);
|
||||
expect(row.dead).toBeUndefined();
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a live pane as not dead, with no status or signal', () => {
|
||||
// Measured against tmux 3.2a: a live pane leaves both numeric fields blank.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|0|||1');
|
||||
expect(row.dead).toBe(false);
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a dead pane with its exit status', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1|7|');
|
||||
expect(row.dead).toBe(true);
|
||||
expect(row.exitStatus).toBe(7);
|
||||
expect(row.exitSignal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads a dead pane with its killing signal', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1||9');
|
||||
expect(row.dead).toBe(true);
|
||||
expect(row.exitStatus).toBeUndefined();
|
||||
expect(row.exitSignal).toBe(9);
|
||||
});
|
||||
|
||||
it('leaves a status of 0 as 0 rather than dropping it', () => {
|
||||
// The whole point of the field: a clean exit is the case part 2 acts on.
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|1|0|');
|
||||
expect(row.exitStatus).toBe(0);
|
||||
});
|
||||
|
||||
it('calls a non-numeric dead flag unknown rather than false', () => {
|
||||
const [row] = parsePaneRows('codeman-aaaa|1234|?||');
|
||||
expect(row.dead).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns one row per pane of a split session, in tmux order', () => {
|
||||
const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|');
|
||||
expect(rows.map((row) => row.pid)).toEqual([100, 200]);
|
||||
expect(rows.map((row) => row.sessionName)).toEqual(['codeman-aaaa', 'codeman-aaaa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePaneExits', () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it('reports a single dead pane with its exit status', () => {
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1|0|'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { status: 0, at: NOW } });
|
||||
});
|
||||
|
||||
it('reports a signalled death without inventing a status', () => {
|
||||
// Folding an absent status into 0 would turn an unexplained death into the
|
||||
// clean exit part 2 closes on sight.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||9'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { signal: 9, at: NOW } });
|
||||
});
|
||||
|
||||
it('reports a death tmux could not explain at all', () => {
|
||||
// Measured on tmux 3.2a: a SIGKILLed pane reports pane_dead=1 and nothing else.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|1||'), NOW);
|
||||
expect(exits.get('codeman-aaaa')).toEqual({ panePid: 1234, exit: { at: NOW } });
|
||||
});
|
||||
|
||||
it('says nothing about a live pane', () => {
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|1234|0|||'), NOW);
|
||||
expect(exits.has('codeman-aaaa')).toBe(false);
|
||||
});
|
||||
|
||||
it('says nothing about a pane whose deadness tmux did not report', () => {
|
||||
expect(derivePaneExits(parsePaneRows('codeman-aaaa|1234'), NOW).size).toBe(0);
|
||||
});
|
||||
|
||||
it('says nothing about a session with more than one pane, even when all are dead', () => {
|
||||
// A session the user split by hand has no single "the agent" to report on,
|
||||
// and guessing which pane speaks for it could call a live session exited.
|
||||
const exits = derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-aaaa|200|1|0|'), NOW);
|
||||
expect(exits.size).toBe(0);
|
||||
});
|
||||
|
||||
it("answers per session, so one session's split does not silence another", () => {
|
||||
const exits = derivePaneExits(
|
||||
parsePaneRows('codeman-aaaa|100|1|0|\ncodeman-bbbb|200|1|0|\ncodeman-bbbb|201|0|||'),
|
||||
NOW
|
||||
);
|
||||
expect([...exits.keys()]).toEqual(['codeman-aaaa']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TmuxManager pane-exit bookkeeping', () => {
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
it('reports nothing before any tick has run', () => {
|
||||
const manager = new TmuxManager();
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the timestamp of the FIRST tick that saw an unchanged exit', () => {
|
||||
// The stamp says when the agent was found gone, so a pane that stays dead
|
||||
// must not have its age reset every two seconds.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW });
|
||||
});
|
||||
|
||||
it('starts a new observation when the exit status changes', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|137|'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 137, at: NOW + 2000 });
|
||||
});
|
||||
|
||||
it('forgets the exit once the same session reports a live pane', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prunes an exit for a session an authoritative read did not mention', () => {
|
||||
// `list-panes -a` lists every pane on the socket, so a session missing from
|
||||
// a successful read has no pane at all and no exit to report. Keeping the
|
||||
// entry would grow the map forever as tmux sessions come and go outside
|
||||
// killSession(). A FAILED or empty read never reaches here — refreshPaneExits
|
||||
// returns before calling this, which is the case the next test covers.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-bbbb|200|1|0|'), NOW));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
expect(manager.getPaneExit('codeman-bbbb')).toEqual({ status: 0, at: NOW });
|
||||
});
|
||||
|
||||
it('starts a new observation when the same status comes from a different pane pid', () => {
|
||||
// A second command in the same pane that also exited 0 is a NEW death, and
|
||||
// its `at` must say so. Only reachable when the respawn bypassed
|
||||
// respawnPane() — a hand-run `tmux respawn-pane` — since every Codeman path
|
||||
// clears the entry outright.
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 60_000));
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toEqual({ status: 0, at: NOW + 60_000 });
|
||||
});
|
||||
|
||||
it('forgets an exit on request, which is what a respawned pane needs', () => {
|
||||
const manager = new TmuxManager();
|
||||
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
|
||||
manager.clearPaneExit('codeman-aaaa');
|
||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pane-exit watcher tick', () => {
|
||||
// Every guard in `refreshPaneExits()` used to be unreachable: the method
|
||||
// began with `if (IS_TEST_MODE) return;`, so deleting the generation check,
|
||||
// the in-flight suppression, the empty-read rule or the read gate left the
|
||||
// whole suite green. The tmux read now sits alone in `readPaneRows()`, which
|
||||
// a subclass can answer for.
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
class TestManager extends TmuxManager {
|
||||
rows: PaneRow[] = [];
|
||||
reads = 0;
|
||||
/** While true, a read parks until releaseAll(), so a test can hold one in flight. */
|
||||
hold = false;
|
||||
private pending: (() => void)[] = [];
|
||||
|
||||
protected override async readPaneRows(): Promise<PaneRow[]> {
|
||||
this.reads++;
|
||||
// Every parked read is tracked, not just the latest: with the in-flight
|
||||
// guard removed a second one starts, and a harness that could release
|
||||
// only the last would deadlock instead of failing.
|
||||
if (this.hold) await new Promise<void>((resolve) => this.pending.push(resolve));
|
||||
return this.rows;
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
this.hold = false;
|
||||
for (const resolve of this.pending.splice(0)) resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const localSession = (sessionId = 's1'): MuxSession =>
|
||||
({
|
||||
sessionId,
|
||||
muxName: `codeman-${sessionId}`,
|
||||
pid: 100,
|
||||
createdAt: 0,
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
attached: true,
|
||||
}) as MuxSession;
|
||||
|
||||
const withLocalSession = () => {
|
||||
const manager = new TestManager();
|
||||
manager.registerSession(localSession());
|
||||
return manager;
|
||||
};
|
||||
|
||||
it('does not read tmux when no session could answer', async () => {
|
||||
const manager = new TestManager();
|
||||
await manager.refreshPaneExits(NOW);
|
||||
expect(manager.reads).toBe(0);
|
||||
});
|
||||
|
||||
it('reads tmux once a local session exists', async () => {
|
||||
const manager = withLocalSession();
|
||||
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
|
||||
await manager.refreshPaneExits(NOW);
|
||||
expect(manager.reads).toBe(1);
|
||||
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 0, at: NOW });
|
||||
});
|
||||
|
||||
it('suppresses a second read while one is still in flight', async () => {
|
||||
// EXEC_TIMEOUT_MS is 5000 against a 2000 ms tick, so a slow read outlives
|
||||
// two ticks; without this the older one can resolve last and win.
|
||||
const manager = withLocalSession();
|
||||
manager.hold = true;
|
||||
const first = manager.refreshPaneExits(NOW);
|
||||
const second = manager.refreshPaneExits(NOW);
|
||||
const reads = manager.reads;
|
||||
manager.releaseAll();
|
||||
await Promise.all([first, second]);
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
it('retracts nothing when the read comes back empty', async () => {
|
||||
// An empty read is "tmux did not answer". Retracting there would turn a
|
||||
// transient failure into a silent denial of a death already observed.
|
||||
const manager = withLocalSession();
|
||||
manager.rows = parsePaneRows('codeman-s1|100|1|137|');
|
||||
await manager.refreshPaneExits(NOW);
|
||||
manager.rows = [];
|
||||
await manager.refreshPaneExits(NOW + 2000);
|
||||
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
|
||||
});
|
||||
|
||||
it('discards a read that started before the pane was cleared', async () => {
|
||||
// The guard that stops an in-flight read from republishing a death over
|
||||
// the pane that has just replaced it.
|
||||
const manager = withLocalSession();
|
||||
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
|
||||
manager.hold = true;
|
||||
const pending = manager.refreshPaneExits(NOW);
|
||||
manager.clearPaneExit('codeman-s1');
|
||||
manager.releaseAll();
|
||||
await pending;
|
||||
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('announces each tick so the server can publish it', async () => {
|
||||
// Losing this emit, or the server's own startPaneExitWatcher() call,
|
||||
// disables the whole feature with nothing failing.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = withLocalSession();
|
||||
manager.rows = parsePaneRows('codeman-s1|100|1||9');
|
||||
const updates: number[] = [];
|
||||
manager.on('paneExitsUpdated', () => updates.push(1));
|
||||
manager.startPaneExitWatcher(10);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
manager.stopPaneExitWatcher();
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
expect(manager.getPaneExit('codeman-s1')).toMatchObject({ signal: 9 });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasObservablePaneSession', () => {
|
||||
// The pane-exit watcher is always-on, so a tick with nothing to observe is
|
||||
// the normal case on an instance running only remote or Docker work. This
|
||||
// predicate is what keeps that tick from exec'ing tmux to find out.
|
||||
const base = {
|
||||
sessionId: 's1',
|
||||
muxName: 'codeman-aaaa',
|
||||
pid: 100,
|
||||
createdAt: 0,
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude' as const,
|
||||
attached: true,
|
||||
};
|
||||
|
||||
it('says no for an empty manager', () => {
|
||||
expect(hasObservablePaneSession([])).toBe(false);
|
||||
});
|
||||
|
||||
it('says yes for a local session, which is the whole reason the watcher runs', () => {
|
||||
expect(hasObservablePaneSession([base])).toBe(true);
|
||||
});
|
||||
|
||||
it('says no for a remote session, whose local pane holds the ssh client', () => {
|
||||
expect(hasObservablePaneSession([{ ...base, remote: { host: 'box', user: 'me' } }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says no for a Docker case, whose local pane holds a `docker exec`', () => {
|
||||
expect(hasObservablePaneSession([{ ...base, docker: { containerName: 'c1' } }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says no for a record rebuilt from the socket, which carries no provenance', () => {
|
||||
// `reconcileSessions()` gives it a synthetic id that matches no state.json
|
||||
// entry, so a remote session rediscovered that way looks local. Session
|
||||
// forces UNKNOWN for it, so reading tmux for it buys nothing.
|
||||
expect(hasObservablePaneSession([{ ...base, discovered: true }])).toBe(false);
|
||||
});
|
||||
|
||||
it('says yes when one local session sits among sessions that cannot answer', () => {
|
||||
// The read is one batched call for the whole socket, so a single local
|
||||
// session is enough to make the tick worth paying for.
|
||||
expect(
|
||||
hasObservablePaneSession([
|
||||
{ ...base, sessionId: 's1', remote: { host: 'box', user: 'me' } },
|
||||
{ ...base, sessionId: 's2', discovered: true },
|
||||
{ ...base, sessionId: 's3' },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The predicate has to agree with `Session.paneExitApplies`, which is where
|
||||
// the rule is enforced; that pairing is pinned in session-pane-exit.test.ts,
|
||||
// where a real Session can answer for itself.
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user