mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(session): publish and persist a local pane's agent exit
The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.
`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.
The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. 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 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.
`status` and `pid` are untouched. `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. A reading that repeats the
previous answer writes nothing and broadcasts nothing.
An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.
`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.
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
02dc46dcd7
commit
90a95f562b
+112
@@ -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 { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -542,6 +543,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;
|
||||
|
||||
@@ -718,6 +733,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. */
|
||||
@@ -870,6 +889,15 @@ 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 stats tick replaces it with a
|
||||
// first-hand reading.
|
||||
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.
|
||||
@@ -1097,6 +1125,71 @@ 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. 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.
|
||||
*/
|
||||
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
|
||||
@@ -1620,6 +1713,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
|
||||
@@ -1760,6 +1857,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) {
|
||||
@@ -1844,6 +1949,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;
|
||||
}
|
||||
@@ -1898,6 +2006,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;
|
||||
}
|
||||
|
||||
@@ -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,14 @@ 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 stats
|
||||
// tick replaces it with a first-hand reading.
|
||||
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 +3588,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.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user