mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(session): persist an exit retraction, and let tests reach the watcher
Ten findings from a two-model review of this branch. Both reviewers cleared the
detection logic itself; everything here is a gap around it.
A route that starts a command in a pane now PERSISTS as well as broadcasts.
`/interactive` and `/shell` did neither before, and the pane-exit watcher cannot
cover for them: its next tick finds `paneExit` already cleared in memory,
reports no change and writes nothing, so `state.json` kept saying the agent had
exited for as long as the session stayed quiet. Nothing reads that record for a
decision yet, which is exactly why it had to be fixed now — part 2 is designed
to read it. The `clearPaneExitForNewPane()` docstring claimed its callers
already persisted; that claim was false for these two, and now says what the
caller owes instead.
The watcher's four guards were unreachable by any test. `refreshPaneExits()`
opened with `if (IS_TEST_MODE) return;`, so the read gate, the in-flight
suppression, the generation counter and the empty-read rule could each be
deleted with the whole suite green. The tmux call moves into `readPaneRows()`,
which a test subclass overrides — the shape `runRemoteReconnectTick` already
uses in this file for the same reason — and the test-mode gate moves with it, so
what a test cannot do is spawn a process rather than exercise the bookkeeping.
Each of the four guards now has a test that fails when it is deleted.
The muted status dot turned out to be a specificity fight on three surfaces, not
two. `.tab-status.error` was not excluded, so a session whose agent exited and
whose PTY-exit breaker then tripped lost its red dot to the mute — the state the
browser answers with a "restart it?" confirm, and a needs-you colour by the same
argument that protects the two alert classes. And mobile.css gives a `busy` dot
a 9px size and a green glow with `!important`, while `status` stays `busy` for a
pane whose agent died mid-turn, so a phone rendered a grey dot still wearing the
green halo beside a badge reading "exited". Both measured against the real
stylesheets, both now excluded, and the CSS test reads mobile.css too instead of
being structurally blind to half the problem.
Six comments said things that were not true. Two named the stats collector as
what replaces a restored reading, which is the opposite of the design. The
interval constant argued that 2000 ms keeps a read inside a tick, when the
5000 ms exec timeout means it cannot — which is why the in-flight guard exists.
`MuxSession.discovered` did not say the flag is permanent, though `saveSessions()`
serializes it. The empty-read docstring claimed a distinction that `|| true`
makes impossible. The invariants doc promised more than its drift test delivers.
And CLAUDE.md had no pointer at all, leaving its two hardest prohibitions
("never set `status: 'error'`", "never null the pid") only in the file it is
meant to route people to.
Refs Ark0N/Codeman#446.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
90fd0a5a15
commit
9c286eeddf
@@ -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.
|
||||
|
||||
@@ -164,7 +164,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
|
||||
|
||||
### 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 because drift is silent in both directions. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought twice.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes `.tab-alert-action`/`.tab-alert-idle` BY HAND: a session blocked on a human outranks "the agent exited", and red must survive the exit. The rich tab rail then needs a SECOND 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. `test/session-pane-exit-ui.test.ts` resolves the real stylesheet in jsdom rather than matching selector text, so both the ordering and the hand-written exclusions fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.test.ts`, `test/session-pane-exit-ui.test.ts`.
|
||||
**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
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ export interface MuxSession {
|
||||
* 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;
|
||||
}
|
||||
|
||||
+10
-5
@@ -895,8 +895,9 @@ export class Session extends EventEmitter {
|
||||
// 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 stats tick replaces it with a
|
||||
// first-hand reading.
|
||||
// (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
|
||||
@@ -1156,9 +1157,13 @@ export class Session extends EventEmitter {
|
||||
* 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. Each caller is already followed
|
||||
* by the route's or recovery's own persist, and the pane-exit watcher would
|
||||
* reach the same answer within one interval regardless.
|
||||
* 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);
|
||||
|
||||
+46
-20
@@ -157,8 +157,9 @@ 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,
|
||||
* and kept well under EXEC_TIMEOUT_MS so a normal read finishes inside a tick.
|
||||
* 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;
|
||||
|
||||
@@ -3079,9 +3080,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* polls rather than probing per session.
|
||||
*
|
||||
* A failed or empty probe leaves the previous answers ALONE rather than
|
||||
* clearing them. An empty read is "tmux did not answer", and clearing on it
|
||||
* would turn a transient failure into a silent retraction of a death Codeman
|
||||
* had already observed. A NON-empty read is different: `list-panes -a` lists
|
||||
* 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.
|
||||
*
|
||||
@@ -3096,8 +3101,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* command in a pane calls {@link clearPaneExit} itself.
|
||||
*/
|
||||
async refreshPaneExits(now: number = Date.now()): Promise<void> {
|
||||
if (IS_TEST_MODE) return;
|
||||
// Nothing on this socket could answer, so do not exec tmux to find that
|
||||
// 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;
|
||||
@@ -3106,18 +3110,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.paneExitReadInFlight = true;
|
||||
let rows: PaneRow[];
|
||||
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,
|
||||
});
|
||||
rows = parsePaneRows(stdout.trim());
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to read pane exit state:', err);
|
||||
return;
|
||||
rows = await this.readPaneRows();
|
||||
} finally {
|
||||
this.paneExitReadInFlight = false;
|
||||
}
|
||||
@@ -3130,6 +3123,37 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
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.
|
||||
@@ -3190,7 +3214,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
clearInterval(this.paneExitInterval);
|
||||
}
|
||||
this.paneExitInterval = setInterval(() => {
|
||||
if (IS_TEST_MODE) return;
|
||||
// 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2458,18 +2458,23 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
`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`.
|
||||
|
||||
⚠ The two alert classes 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 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 {
|
||||
⚠ 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::after {
|
||||
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -18511,7 +18516,7 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
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 {
|
||||
.tab-status:not(.error) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
+3
-2
@@ -3373,8 +3373,9 @@ export class WebServer extends EventEmitter {
|
||||
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 stats
|
||||
// tick replaces it with a first-hand reading.
|
||||
// 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`).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -148,37 +148,47 @@ describe('what colour the status dot ends up', () => {
|
||||
* a real engine answers, which is what makes a rule moved up the file or a
|
||||
* selector given one more class fail here.
|
||||
*
|
||||
* ⚠ Rules inside an at-rule are skipped, so this describes a desktop-width
|
||||
* tab strip with motion allowed. jsdom reports a custom property unresolved,
|
||||
* so the expected values are the `var(--x)` tokens the stylesheet writes.
|
||||
* ⚠ 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 css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||
|
||||
const dotRules: string[] = [];
|
||||
postcss.parse(css).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) return;
|
||||
const decls: string[] = [];
|
||||
rule.each((node) => {
|
||||
if (node.type === 'decl') decls.push(`${node.prop}: ${node.value}${node.important ? ' !important' : ''};`);
|
||||
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(' ')} }`);
|
||||
});
|
||||
if (decls.length === 0) return;
|
||||
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
|
||||
dotRules.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 }) => {
|
||||
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>${dotRules.join('\n')}</style></head><body>` +
|
||||
`<!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>`
|
||||
);
|
||||
@@ -240,6 +250,37 @@ describe('what colour the status dot ends up', () => {
|
||||
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
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
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)
|
||||
@@ -1122,6 +1124,124 @@ describe('TmuxManager pane-exit bookkeeping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user