mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(tmux): gate the pane-exit read, and mute the dot on the rich rail too
Four changes the maintainer asked for on Ark0N/Codeman#446 before merging. The pane-exit watcher stays always-on, but a tick now costs nothing when there is nothing to observe. `hasObservablePaneSession()` skips the tmux exec while every session on the manager is one of the shapes `Session.paneExitApplies` already forces to UNKNOWN: a remote SSH session (its local pane holds the ssh client), a docker case (a `docker exec` into the container's own tmux), and a record rebuilt from the socket (no provenance at all). The timer is untouched. Skipping retracts nothing, for the same reason a failed read does not: the map still holds the last real reading, and every path that puts a new command in a pane calls `clearPaneExit()` itself. The two copies of that rule are pinned against each other in `test/session-pane-exit.test.ts`, because drift between them is silent in both directions. `DEFAULT_PANE_EXIT_INTERVAL_MS` was already a constant beside the stats and remote-reconnect intervals; its comment now says why the watcher owns its own cadence and why the number is what it is. The never-default-an-absent-status rule is written where `PaneExit` is declared. It names `status ?? 0` as the thing never to write, and says that an agent the OOM killer took would otherwise read as a user typing `/exit` — which is what absent-stays-absent keeps a later clean-exit sweep away from. Nothing fails when somebody adds that `??`, which is why the sentence is there rather than a test. Checking the dot's specificity found a second fight, and it was losing. On the tab strip the alert rules win as intended: a session that exits with a permission dialog pending still renders red, and yellow for an idle alert. On the rich vertical tab rail they did not — that rail's own `tab-state-*` dot rules are (0,9,1) against the strip's mute at (0,5,0), so an exited session there kept a full green dot AND the working halo beside a badge reading "exited". The rail twin matches that specificity exactly and therefore must stay below those rules in source order; it clears the halo as well, which the strip's rule never had to think about. `test/session-pane-exit-ui.test.ts` now resolves the real stylesheet in jsdom rather than matching selector text: postcss collects every rule that paints `.tab-status`, a real engine decides, and the tests read back the answer. Two mutations were run against it to prove it has teeth — dropping the hand-written alert exclusions fails three cases, and moving the rail twin above the state rules fails one. 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
c67c130caa
commit
90fd0a5a15
@@ -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`)
|
### 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. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.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 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`.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -153,7 +153,13 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
|||||||
/** Default stats collection interval (2 seconds) */
|
/** Default stats collection interval (2 seconds) */
|
||||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
/** How often the pane-exit watcher re-reads every pane on the socket. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
|
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||||
@@ -359,6 +365,33 @@ export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneE
|
|||||||
return exits;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
||||||
* Prefers the active pane and falls back to the first valid pane.
|
* Prefers the active pane and falls back to the first valid pane.
|
||||||
@@ -3055,9 +3088,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* Two guards keep a slow read from undoing a fast one. A read already in
|
* 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
|
* flight suppresses the next poll, and a read that started before a
|
||||||
* {@link clearPaneExit} is discarded when it lands.
|
* {@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> {
|
async refreshPaneExits(now: number = Date.now()): Promise<void> {
|
||||||
if (IS_TEST_MODE) return;
|
if (IS_TEST_MODE) return;
|
||||||
|
// Nothing on this socket could answer, so do not exec tmux to find that
|
||||||
|
// out. See `hasObservablePaneSession`: the watcher above still ticks.
|
||||||
|
if (!hasObservablePaneSession(this.sessions.values())) return;
|
||||||
if (this.paneExitReadInFlight) return;
|
if (this.paneExitReadInFlight) return;
|
||||||
|
|
||||||
const generation = this.paneExitGeneration;
|
const generation = this.paneExitGeneration;
|
||||||
|
|||||||
+15
-1
@@ -645,9 +645,23 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
|
|||||||
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
|
* 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
|
* 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".
|
* 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 {
|
export interface PaneExit {
|
||||||
/** tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux reported none. */
|
/**
|
||||||
|
* 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;
|
status?: number;
|
||||||
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
|
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
|
||||||
signal?: number;
|
signal?: number;
|
||||||
|
|||||||
@@ -18497,6 +18497,26 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
|||||||
border-width: 2px;
|
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 {
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Card accents: the same three colours as every other session surface, and the
|
/* 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
|
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
|
on top of this for the sessions that are genuinely blocked on a human; these
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
* @fileoverview The exited-agent badge on a session tab (Ark0N/Codeman#446).
|
* @fileoverview The exited-agent badge on a session tab (Ark0N/Codeman#446).
|
||||||
*
|
*
|
||||||
* The server publishes `session.paneExit` when the agent inside a local tmux
|
* 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 two
|
* pane has exited while `remain-on-exit` kept the pane. These cover the three
|
||||||
* halves the browser owns: turning that field into a label, and getting the
|
* things the browser owns: turning that field into a label, getting the label
|
||||||
* label onto and off a tab.
|
* 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.
|
* 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
|
* Going from live to exited adds and removes no tab, so the full rebuild never
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { JSDOM } from 'jsdom';
|
import { JSDOM } from 'jsdom';
|
||||||
|
import postcss from 'postcss';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('the exited-agent tab label', () => {
|
describe('the exited-agent tab label', () => {
|
||||||
@@ -120,19 +122,6 @@ describe('the exited-agent badge in a tab', () => {
|
|||||||
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
|
expect(tab.classList.contains('tab-agent-exited')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never quiets a dot that an alert has claimed', () => {
|
|
||||||
// A dot turning red or yellow because a session is blocked on a human
|
|
||||||
// outranks "the agent exited", so the CSS excludes both alert classes by
|
|
||||||
// hand rather than relying on the cascade.
|
|
||||||
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
|
||||||
const rules = css.match(/\.session-tab\.tab-agent-exited[^{]*\{/g) ?? [];
|
|
||||||
expect(rules.length).toBeGreaterThan(0);
|
|
||||||
for (const rule of rules) {
|
|
||||||
expect(rule).toContain(':not(.tab-alert-action)');
|
|
||||||
expect(rule).toContain(':not(.tab-alert-idle)');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the badge when the pane comes back', () => {
|
it('removes the badge when the pane comes back', () => {
|
||||||
// The retraction half: a respawned pane must not keep reading "exited".
|
// The retraction half: a respawned pane must not keep reading "exited".
|
||||||
const tab = makeTab();
|
const tab = makeTab();
|
||||||
@@ -145,3 +134,115 @@ describe('the exited-agent badge in a tab', () => {
|
|||||||
expect(appJs).toContain('applyPaneExitBadge(tab, session.paneExit)');
|
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.
|
||||||
|
*
|
||||||
|
* ⚠ 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.
|
||||||
|
*/
|
||||||
|
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' : ''};`);
|
||||||
|
});
|
||||||
|
if (decls.length === 0) return;
|
||||||
|
const selectors = rule.selectors.map((sel) => (parents.length ? `${parents.join(' ')} ${sel}` : sel));
|
||||||
|
dotRules.push(`${selectors.join(',')} { ${decls.join(' ')} }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Paint the dot of one tab and read back what the cascade decided. */
|
||||||
|
const dot = (opts: { tab: string; dotState?: string; rail?: boolean }) => {
|
||||||
|
const railAttrs = opts.rail ? ` data-tab-orientation="vertical" data-tab-rail-detail="rich"` : '';
|
||||||
|
const container = opts.rail ? 'tab-rail' : 'session-tabs';
|
||||||
|
const dom = new JSDOM(
|
||||||
|
`<!DOCTYPE html><html${railAttrs}><head><style>${dotRules.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('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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
import { StateStore } from '../src/state-store.js';
|
import { StateStore } from '../src/state-store.js';
|
||||||
import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../src/types.js';
|
import type { PaneExit, SessionRemote, SessionDocker, SessionState } from '../src/types.js';
|
||||||
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
|
||||||
|
import { hasObservablePaneSession } from '../src/tmux-manager.js';
|
||||||
|
|
||||||
const PORT = 3187;
|
const PORT = 3187;
|
||||||
|
|
||||||
@@ -118,6 +119,43 @@ describe('Session.setPaneExit scoping', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('Session.toState with an exited agent', () => {
|
||||||
it('publishes the exit and leaves status and pid alone', () => {
|
it('publishes the exit and leaves status and pid alone', () => {
|
||||||
const session = localMuxSession();
|
const session = localMuxSession();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
formatPaneSnapshot,
|
formatPaneSnapshot,
|
||||||
parsePaneRows,
|
parsePaneRows,
|
||||||
derivePaneExits,
|
derivePaneExits,
|
||||||
|
hasObservablePaneSession,
|
||||||
resolveActivePaneTarget,
|
resolveActivePaneTarget,
|
||||||
} from '../src/tmux-manager.js';
|
} from '../src/tmux-manager.js';
|
||||||
import { execSync, exec } from 'node:child_process';
|
import { execSync, exec } from 'node:child_process';
|
||||||
@@ -1120,3 +1121,57 @@ describe('TmuxManager pane-exit bookkeeping', () => {
|
|||||||
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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