feat(session): close sessions whose agent exited cleanly (#486)

* fix(cleanup): keep .claude-images while a sibling session uses the same dir

cleanupSession() recursively removes {workingDir}/.claude-images. That
directory belongs to the working directory rather than to the session, and
several sessions routinely share one case directory, so closing one session
deleted the pasted images a live sibling still referred to.

The removal now runs only when no other live session has the same working
directory. A session that is itself being cleaned up does not count as live,
so two sessions of one case closed together still remove the dir.

Split out ahead of the exited-agent sweep for Ark0N/Codeman#446, which closes
sessions unattended and would otherwise make the loss routine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(session): close sessions whose agent exited cleanly (#446)

Part 2 of Ark0N/Codeman#446. Part 1 records an exited agent as
SessionState.paneExit. A session whose agent the user ended with /exit is
now closed through cleanupSession(), the same path the X button takes, so
finished sessions stop piling up on the board. The lifecycle log records
the reason as "agent exited cleanly (status 0)", and the conversation stays
resumable from the Resume list.

shouldCloseCleanlyExitedSession() in the new pure module pane-exit-sweep.ts
holds the rule. It closes a session only when all of these hold:

- The exit status is an explicit numeric 0 with no signal. An absent status
  is how a SIGKILL presents on tmux 3.2a, so it counts as unknown and the
  row stays. A non-zero status or any signal also keeps the row, with the
  exit code on the tab.
- Two authoritative pane reads agreed on that exit.
  TmuxManager.getPaneExitReadCount() counts them, and a failed, empty or
  skipped read neither confirms nor resets the count.
- No start, attach or relaunch is running for the pane.
  Session.paneLifecycleInFlight covers _setupOrAttachMuxSession(), whose
  dead-pane branch revives an exited pane on purpose, and restartCli().

setPaneExit() already scopes paneExit to local mux-backed sessions, so
remote, docker and direct-PTY sessions are never closed.

planRebootRestore() now refuses a record whose persisted paneExit is a
clean exit. That covers an agent that exited just before a reboot, before
the sweep reached it. A crashed agent's record stays eligible, like its row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(web): show "exited" on the phone overview and desktop home rail (#446)

Part 1 of Ark0N/Codeman#446 taught the tab strip and the rich rail rows
to say that a session's agent has exited. The phone overview and the
desktop home rail still said "idle", beside a green or pulsing dot.

_mobileOverviewExit() in mobile-overview.js is now the one rule for all
three surfaces, and _sidebarRichRow() uses it as well. It changes what a
row shows and leaves the row's state alone, because the state still picks
the section and the sort order. An exited row gets an "exited" pill, a
neutral dot and row accent, and a duration measured from when the server
first saw the pane dead. A pending permission prompt or question still
wins, as it does on the tab.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cleanup): close the gaps review found in the #446 sweep and image guard

Four fixes from a dual review of Ark0N/Codeman#446 part 2.

- The .claude-images guard compares canonical paths, so a sibling that
  reaches the same directory through a symlink keeps it. Its comment used to
  say that case only missed a deletion; it caused one.
- A detached session counts as a live sibling. DELETE ?killMux=false removes
  it from the server's map while its pane keeps running, so the guard now
  reads persisted records too, and exempts only sessions being killed rather
  than every session in cleaningUp.
- A session being closed refuses startInteractive() and startShell(). The
  /interactive route awaits listener setup before the start, and a start
  that raced the close could launch a CLI in a tmux session whose record was
  then deleted. A failed close clears the mark again.
- The clean-exit sweep tries each exit once, keyed by session id and the
  exit's at stamp, so a close that fails is not retried and logged every
  two seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(session): keep a clean exit that lands within 10 s of a pane start (#446)

A CLI that prints a startup error ("not logged in", a bad profile, a config
error) and exits 0 used to lose its tab, and the error with it, about 4 s
after launch. The sweep now keeps any clean exit that lands within
CLEAN_EXIT_MIN_PANE_LIFETIME_MS (10 s) of the last start, attach or relaunch
finishing (Session.paneStartedAt, stamped when _withPaneLifecycle ends). The
row stays as "exited (0)" for the user to read and close.

Verified on an isolated instance: a shell that ran `exit 0` 2 s after start
kept its row, one that exited after 13 s was closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-24 22:18:07 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d67da5c9d0
commit b80d47aff8
21 changed files with 1253 additions and 50 deletions
+1 -1
View File
@@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
**Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin)
File diff suppressed because one or more lines are too long
+8
View File
@@ -336,6 +336,14 @@ export interface TerminalMultiplexer extends EventEmitter {
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/**
* How many authoritative pane reads have agreed on the exit `getPaneExit()`
* reports, or 0 when it reports none. The exited-agent sweep closes a session
* only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`),
* and a multiplexer without this method never has a session closed by it.
*/
getPaneExitReadCount?(muxName: string): number;
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void;
+99
View File
@@ -0,0 +1,99 @@
/**
* @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446).
*
* Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the
* CLI while the pane, the tmux session and the `tmux attach-session` process
* all live on. Part 1 of #446 records that as `SessionState.paneExit`. This
* module decides when such a session is closed, the way the X button closes
* it, so finished sessions stop piling up on the board.
*
* The rule closes a session only on a POSITIVE observation of a clean exit:
*
* - The exit status must be an explicit numeric 0 with no signal. An absent
* status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a
* status nor a signal, so reading absence as clean would sweep an agent the
* OOM killer took. A non-zero status or any signal keeps the row, marked with
* the exit, as the crash evidence #210 was filed to keep.
* - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must
* have agreed on that exit. A failed, empty or skipped read counts for
* nothing, because unknown never closes anything.
* - No start, attach or relaunch may be in flight for the session. The
* dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited
* pane on purpose, and for a few seconds that pane still reads as dead.
* - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after
* the last start, attach or relaunch finished. A CLI that prints a startup
* error ("not logged in", a bad profile, a config error) and exits 0 would
* otherwise lose its tab, and the error with it, seconds after launch. Its
* row stays, marked `exited (0)`, for the user to read and close.
*
* Scoping to local mux-backed sessions happens before this rule runs:
* `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote,
* docker and discovered sessions, so their `paneExit` never reaches here.
*
* Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts).
*/
import type { PaneExit } from './types/index.js';
/**
* How many authoritative pane reads must agree on a clean exit before the
* session is closed. At the watcher's 2 s cadence two reads mean a finished
* session disappears within about four seconds of its agent exiting.
*/
export const CLEAN_EXIT_CONFIRMING_READS = 2;
/**
* How long a pane must have been up before a clean exit closes its session.
* An exit sooner than this after the last pane start is read as a startup
* failure rather than a user ending the agent, and the row is kept.
*/
export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000;
/** The lifecycle-log reason recorded when the sweep closes a session. */
export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)';
/**
* Is this exit a clean one? True only for an explicit numeric status of 0 with
* no signal reported.
*
* ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was
* clean". An absent status is how a signal death presents on tmux 3.2a, and
* that shortcut would close crashed agents with nothing failing to warn you.
*/
export function isCleanPaneExit(exit: PaneExit | undefined): boolean {
if (!exit) return false;
if (exit.signal !== undefined) return false;
return exit.status === 0;
}
/** Everything the sweep needs to know about one session. */
export interface CleanExitSweepCandidate {
/** The session's published exit, already scoped by `Session.setPaneExit()`. */
paneExit: PaneExit | undefined;
/** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */
confirmingReads: number;
/** A start, attach or relaunch is running for this session's pane. */
paneLifecycleInFlight: boolean;
/** The session is already being closed or detached. */
closing: boolean;
/**
* When the last start, attach or relaunch of this pane finished
* (`Session.paneStartedAt`), or 0 when none has run in this process.
*/
paneStartedAt: number;
}
/** Should the sweep close this session now? See the file overview for the rule. */
export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean {
if (candidate.closing) return false;
if (candidate.paneLifecycleInFlight) return false;
if (!isCleanPaneExit(candidate.paneExit)) return false;
// `at` is when this server first read the pane dead, so an exit during the
// start itself lands BEFORE `paneStartedAt` and is kept too.
if (
candidate.paneStartedAt > 0 &&
candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS
) {
return false;
}
return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS;
}
+27 -14
View File
@@ -19,19 +19,22 @@
* touching its status, so a pinned session a reboot killed still reads `idle` or
* `busy` and stays eligible.
*
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the
* `tmux attach-session` process, which stays alive throughout — so no exit
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman
* notice the dead pane; until a record can say the agent is gone, this pass will
* offer those sessions back, and the user dismisses or closes them.
* ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
* `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
* and the PTY Codeman owns is the `tmux attach-session` process, which stays
* alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
* and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
* handles it in two steps. The pane-exit watcher persists `paneExit`, and the
* clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
* with status 0 through `cleanupSession()`, which leaves the durable record
* described above. This module also refuses a record whose persisted
* `paneExit` is a clean exit, which covers a session that exited moments
* before the power went, before the sweep reached it. A crashed agent's record
* stays eligible, like the row the sweep leaves on the board for it.
*
* The `pid` check below is therefore NOT that rule. It refuses a record whose
* attach process was already gone, which is a session that never started or
* whose pane died outright.
* The `pid` check below is NOT that rule. It refuses a record whose attach
* process was already gone, which is a session that never started or whose
* pane died outright.
*
* @dependencies types (SessionState), config/cli-registry
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
@@ -41,6 +44,7 @@
import type { SessionState } from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { isCleanPaneExit } from './pane-exit-sweep.js';
/** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']);
@@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string {
/**
* Why one session was passed over. Reported for logging and shown to the user.
*
* The first seven are decided before anything is built. `capacity-reached` and
* All but the last two are decided before anything is built. `capacity-reached` and
* `rebuild-failed` can only happen once a click is spending the plan, and they
* are the two the banner must not confuse with a missing workspace: one means
* "try again after closing something", the other means the CLI would not start.
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
| 'no-persisted-record'
| 'intentionally-ended'
| 'not-running'
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'unsupported-mode'
@@ -191,7 +196,8 @@ export function planRebootRestore(
//
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the
// module header: that leaves the pid in place, because the pid is the tmux
// attach process and `remain-on-exit` keeps it alive.
// attach process and `remain-on-exit` keeps it alive. The `paneExit` check
// below catches it instead.
//
// Conservative on purpose. A session that somehow persisted no pid while
// genuinely running is not offered, and its conversation stays reachable
@@ -200,6 +206,13 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'not-running' });
continue;
}
if (isCleanPaneExit(state.paneExit)) {
// The user ended the agent, and the clean-exit sweep would have closed the
// session had the power not gone first (Ark0N/Codeman#446). The same
// explicit-0 rule applies: an absent status is unknown, not clean.
skipped.push({ sessionId, reason: 'agent-exited' });
continue;
}
if (state.respawnBlocked === true) {
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
skipped.push({ sessionId, reason: 'respawn-blocked' });
+76
View File
@@ -582,6 +582,21 @@ export class Session extends EventEmitter {
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* How many starts, attaches or relaunches are running for this session's
* pane. While one is, a dead-pane reading may describe a pane that is being
* revived on purpose, so the exited-agent sweep leaves the session alone
* (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping
* operations cannot clear each other's mark.
*/
private _paneLifecycleOps = 0;
/** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */
private _paneStartedAt = 0;
/**
* The server has started closing this session, so no start or attach may
* begin (see {@link markClosing}).
*/
private _closing = false;
/**
* 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
@@ -1183,6 +1198,49 @@ export class Session extends EventEmitter {
return this._paneExit ?? undefined;
}
/**
* True while a start, attach or relaunch is running for this session's pane.
* The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane
* branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and
* until it finishes and clears the exit, the pane still reads as dead.
*/
get paneLifecycleInFlight(): boolean {
return this._paneLifecycleOps > 0;
}
/**
* When the last start, attach or relaunch of this pane finished, or 0 when
* none has run in this process. The exited-agent sweep keeps an exit that
* lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a
* CLI failing at startup rather than a user ending it. An attach to a pane
* that was already running stamps it too, which only costs a user who
* `/exit`s within seconds of a server restart a row to close by hand.
*/
get paneStartedAt(): number {
return this._paneStartedAt;
}
/**
* Mark this session as being closed, or clear the mark after a close that
* failed. While it is set, {@link startInteractive} and {@link startShell}
* refuse to run. A start that raced a close would otherwise launch a CLI in a
* tmux session whose record is about to be deleted (Ark0N/Codeman#446).
*/
markClosing(closing: boolean): void {
this._closing = closing;
}
/** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */
private async _withPaneLifecycle<T>(op: () => Promise<T>): Promise<T> {
this._paneLifecycleOps++;
try {
return await op();
} finally {
this._paneLifecycleOps--;
this._paneStartedAt = Date.now();
}
}
/**
* 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
@@ -1888,6 +1946,14 @@ export class Session extends EventEmitter {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options));
}
private async _doSetupOrAttachMuxSession(options: {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
const mux = this._mux!;
@@ -2071,6 +2137,10 @@ export class Session extends EventEmitter {
* the mux session is gone — see {@link reattachRemote} for that reasoning).
*/
async restartCli(): Promise<boolean> {
return this._withPaneLifecycle(() => this._doRestartCli());
}
private async _doRestartCli(): Promise<boolean> {
if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux;
@@ -2459,6 +2529,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
@@ -3280,6 +3353,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
if (this._closing) {
throw new Error('Session is being closed');
}
this._resetBuffers();
+28 -2
View File
@@ -287,6 +287,19 @@ export interface PaneExitObservation {
exit: PaneExit;
}
/**
* A {@link PaneExitObservation} as the manager stores it, with a count of the
* authoritative reads that have seen this same exit. The count is what lets
* the exited-agent sweep act only on a death that more than one read agreed on
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
* the count nor resets it.
*/
interface TrackedPaneExit extends PaneExitObservation {
/** Authoritative reads that saw this exit, counting the first. */
reads: number;
}
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index];
@@ -1672,7 +1685,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
private paneExits: Map<string, PaneExitObservation> = new Map();
private paneExits: Map<string, TrackedPaneExit> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
@@ -3075,6 +3088,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return this.paneExits.get(muxName)?.exit;
}
/**
* How many authoritative pane reads have agreed on the exit that
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
* starts at 1, and every later read that sees the same pane with the same
* status and signal adds one.
*/
getPaneExitReadCount(muxName: string): number {
return this.paneExits.get(muxName)?.reads ?? 0;
}
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
@@ -3170,6 +3193,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
*
* The same rule decides the read count: a repeat of the stored exit adds one,
* and anything that starts a new observation starts the count again at 1.
*/
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) {
@@ -3182,7 +3208,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
prev.panePid === next.panePid &&
prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal;
this.paneExits.set(muxName, sameExit ? prev : next);
this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 });
}
}
+7 -6
View File
@@ -649,12 +649,13 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
* ⚠ 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.
* the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
* from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
* read as a user typing `/exit` and be closed. Nothing here fails when
* somebody adds that `??` — the types allow it and the label still renders.
* The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
* the key rather than defaulting it, and again in `isCleanPaneExit()`, which
* accepts only an explicit 0.
*/
export interface PaneExit {
/**
+69 -1
View File
@@ -12,7 +12,8 @@
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { realpathSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
return { scanned, deleted };
}
/**
* The path two sessions must share to share a paste-image dir: the canonical
* path when it can be resolved, so a sibling that reaches the same directory
* through a symlink matches, and the normalised path otherwise (a directory
* that no longer exists has nothing left to protect).
*/
function canonicalDir(dir: string): string {
try {
return realpathSync(dir);
} catch {
return resolve(dir);
}
}
/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */
export interface PasteImageDirUser {
id: string;
workingDir: string;
status?: string;
}
/**
* Does another live session still use this working directory's paste-image
* dir? Deleting a session removes `{workingDir}/.claude-images` recursively,
* and several sessions routinely share one case directory, so without this
* check closing one session deletes the pasted images a sibling in the same
* case still refers to.
*
* Two kinds of sibling count as live:
*
* - a session in the server's map, unless it is itself being killed;
* - a persisted record whose status is not `stopped`. That covers a session
* detached with `killMux=false`, which leaves the server's map while its
* tmux pane keeps running, and a session whose detach is still in progress.
*
* A session being KILLED does not count. Without that exemption, killing two
* sessions of one case concurrently (a bulk delete, or the exited-agent sweep
* closing two panes on one tick) would have each defer to the other, and
* neither would remove the dir.
*
* Erring toward "in use" only costs a missed deletion, which the periodic
* sweep above ages out. A pinned record whose tmux session is gone keeps its
* status through boot pruning, so it holds the dir this way until unpinned.
*/
export function pasteImageDirInUseByOtherSession(input: {
live: Iterable<PasteImageDirUser>;
persisted: Iterable<PasteImageDirUser>;
closingId: string;
workingDir: string;
killing: ReadonlySet<string>;
}): boolean {
const target = canonicalDir(input.workingDir);
const matches = (user: PasteImageDirUser): boolean =>
user.id !== input.closingId &&
!input.killing.has(user.id) &&
!!user.workingDir &&
canonicalDir(user.workingDir) === target;
for (const user of input.live) {
if (matches(user)) return true;
}
for (const user of input.persisted) {
if (user.status === 'stopped') continue;
if (matches(user)) return true;
}
return false;
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
+5 -10
View File
@@ -4918,9 +4918,10 @@ class CodemanApp {
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
// or busy for an exited pane by design, so without this the muted dot sits
// beside a pill saying "idle". A pending alert still wins, exactly as it
// does for the dot.
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working');
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0;
// does for the dot. The rule is `_mobileOverviewExit()`, shared with both
// home screens so the three surfaces agree on which sessions have exited.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const exited = !!exit;
return {
state,
exited,
@@ -4931,13 +4932,7 @@ class CodemanApp {
// state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0,
since: exitAt
? { key: 'exited', at: exitAt }
: exited
? null
: this._mobileOverviewSince
? this._mobileOverviewSince(state, session)
: null,
since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
};
}
+11 -5
View File
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
// Guarded: a stale cached mobile-overview.js may predate the helper.
const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const mode = session.mode || 'claude';
return {
id,
@@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent (Ark0N/Codeman#446), whose state still sorts it.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state,
// What the pane's footer says is still running in the background, straight off
// the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '',
@@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
lastSubmitAt: Number(session.lastSubmitAt) || 0,
// "how long has it been like this", resolved by the phone overview's
// helper so both home screens label the same stamp with the same word.
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
};
});
@@ -392,7 +397,8 @@ Object.assign(CodemanApp.prototype, {
_buildHomeSessionRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'home-sessions-row home-sessions-row--' + row.state;
const display = row.display || row.state;
item.className = 'home-sessions-row home-sessions-row--' + display;
item.dataset.hsAction = 'session';
item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
@@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
}
const dot = document.createElement('span');
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
dot.className = 'home-sessions-dot home-sessions-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
pill.className = 'home-sessions-pill home-sessions-pill--' + display;
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
// that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+40 -5
View File
@@ -161,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
},
/**
* The exited-agent override for one row (Ark0N/Codeman#446), or null when
* the row shows its state as usual.
*
* The server publishes `session.paneExit` once the agent inside a local tmux
* pane has exited, while `status` stays `idle` or `busy` by design. So a row
* classified as idle or working may really be a pane with nothing running
* in it. This overrides what the row SHOWS, never its `state`: `state` still
* picks the section and the sort, the way `_sidebarRichRow()` (app.js) does
* for the detailed sidebar and rail. A pending alert still wins, because a
* human being blocked outranks the agent having exited.
*
* Shared by the phone overview, the desktop home rail and the rich tab rows,
* so the three cannot disagree about which sessions have exited.
*
* Guarded like every other cross-file call: `paneExitLabel()` lives in
* app.js, and a stale cached app.js must degrade to no override, not throw.
*
* @returns {{since: {key: string, at: number}|null}|null}
*/
_mobileOverviewExit(state, session) {
if (state !== 'idle' && state !== 'working') return null;
if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null;
// `at` is when this server first saw the pane dead, which is what "exited
// 2m" should measure. A row without it shows no duration at all rather
// than a working or idle stamp that no longer describes the pane.
const at = Number(session.paneExit.at) || 0;
return { since: at ? { key: 'exited', at } : null };
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
@@ -202,6 +232,7 @@ Object.assign(CodemanApp.prototype, {
const rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const exit = this._mobileOverviewExit(state, session);
const orderIndex = order.indexOf(session.id);
return {
id: session.id,
@@ -210,7 +241,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the row's dot, accent and pill show. It differs from `state` only
// for an exited agent, whose state still decides the section and sort.
display: exit ? 'exited' : state,
pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"), straight off the session payload. A row that has one is quiet
// because the agent is waiting for that, not because it is waiting for you.
@@ -222,7 +256,7 @@ Object.assign(CodemanApp.prototype, {
// pair resolved for DISPLAY, and the two must not drift apart.
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
since: this._mobileOverviewSince(state, session),
since: exit ? exit.since : this._mobileOverviewSince(state, session),
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
@@ -698,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
_buildMobileOverviewRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
const display = row.display || row.state;
item.className = 'mobile-overview-row mobile-overview-row--' + display;
item.dataset.moAction = 'session';
item.dataset.moSession = row.id;
const dot = document.createElement('span');
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
dot.className = 'mobile-overview-dot mobile-overview-dot--' + display;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
@@ -736,7 +771,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
pill.className = 'mobile-overview-pill mobile-overview-pill--' + display;
// Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
+7
View File
@@ -2978,6 +2978,13 @@ html.mobile-init .file-browser-panel {
color: var(--green);
}
/* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind
the row. The dot and the row take `--exited` too and keep their base rules. */
.mobile-overview-pill--exited {
border-color: var(--text-muted);
color: var(--text-muted);
}
/* Accent, and none of the three above: a session watching work it started itself
is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching {
+9
View File
@@ -16392,6 +16392,15 @@ html[data-tab-orientation='vertical'] .home-sessions {
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
No green at all, since nothing is running behind this row. The dot and the row
take the `--exited` class too and fall back to their neutral base rules. */
.home-sessions-pill--exited {
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
color: var(--text-muted);
}
/* Accent, deliberately none of the three above: a session that is watching something
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
anything, so it must not borrow the red or the yellow that mean it is. This badge
+97 -3
View File
@@ -33,7 +33,8 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
@@ -1321,16 +1322,32 @@ export class WebServer extends EventEmitter {
// Clean up all resources associated with a session
// Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set();
/**
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
* than detached. The paste-image guard needs the difference: a detaching
* session keeps running in tmux and still uses its working directory.
*/
private killingSessions: Set<string> = new Set();
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
// Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId);
if (killMux) this.killingSessions.add(sessionId);
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
// raced this cleanup would launch a CLI in a tmux session whose record is
// about to be deleted, leaving an orphan the next boot rediscovers.
const session = this.sessions.get(sessionId);
session?.markClosing(true);
try {
await this._doCleanupSession(sessionId, killMux, reason);
} finally {
this.cleaningUp.delete(sessionId);
this.killingSessions.delete(sessionId);
// A cleanup that failed leaves the session on the board, so it must be
// startable again.
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
}
}
@@ -1482,8 +1499,24 @@ export class WebServer extends EventEmitter {
attachmentRegistry.clearSession(sessionId);
// Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session
if (killMux && session.workingDir) {
// Clean up pasted images directory for this session. The dir belongs to the
// working directory rather than the session, so it stays while another live
// session in the same case still uses it (Ark0N/Codeman#446).
if (
killMux &&
session.workingDir &&
!pasteImageDirInUseByOtherSession({
live: this.sessions.values(),
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
id,
workingDir: record.workingDir,
status: record.status,
})),
closingId: sessionId,
workingDir: session.workingDir,
killing: this.killingSessions,
})
) {
const pasteImageDir = join(session.workingDir, '.claude-images');
try {
rmSync(pasteImageDir, { recursive: true, force: true });
@@ -2502,6 +2535,9 @@ export class WebServer extends EventEmitter {
* 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.
*
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
* sessions whose agent the user ended.
*/
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
@@ -2515,6 +2551,63 @@ export class WebServer extends EventEmitter {
this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id);
}
this.closeCleanlyExitedSessions();
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
* is demoted to `status: 'stopped'` there rather than removed, and either way
* the reboot restore stops offering it back. The conversation stays
* resumable, since the Resume list reads the lifecycle log and the transcript
* files, and the pane owns neither.
*
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
* an explicit status of 0, confirmed by more than one pane read, with no
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
* code on it. `session.paneExit` is already scoped to local mux-backed
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
* never closed here.
*
* The close runs in the background. `cleanupSession()` ignores a second call
* for a session it is already closing, and the `closing` check below keeps
* the next tick from queueing one.
*
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
* stamp. A close that fails leaves the session on the board with its exit
* badge, which is where a crashed agent's row would be too, rather than
* retrying and logging every two seconds. A new exit in the same pane has a
* new `at` and gets its own attempt.
*/
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
private cleanExitCloseAttempts: Set<string> = new Set();
private closeCleanlyExitedSessions(): void {
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
if (!readCount) return;
// Forget attempts for sessions that are gone, so the set stays bounded.
for (const key of this.cleanExitCloseAttempts) {
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
}
for (const session of [...this.sessions.values()]) {
const muxName = session.muxName;
if (!muxName) continue;
const close = shouldCloseCleanlyExitedSession({
paneExit: session.paneExit,
confirmingReads: readCount(muxName),
paneLifecycleInFlight: session.paneLifecycleInFlight,
closing: this.cleaningUp.has(session.id),
paneStartedAt: session.paneStartedAt,
});
if (!close) continue;
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
if (this.cleanExitCloseAttempts.has(attempt)) continue;
this.cleanExitCloseAttempts.add(attempt);
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
});
}
}
// ========== Web Push ==========
@@ -3963,6 +4056,7 @@ export class WebServer extends EventEmitter {
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
this.killingSessions.clear();
// Dispose push store (flush pending saves)
this.pushStore.dispose();
+194
View File
@@ -0,0 +1,194 @@
// Port: none (pure model + fake-DOM row builders — no browser, no server).
//
// The phone overview and the desktop home rail showing an exited agent as
// "exited" rather than "idle" (Ark0N/Codeman#446).
//
// The server publishes `session.paneExit` when the agent inside a local tmux
// pane has exited, while `status` stays `idle` or `busy` by design. Part 1 of
// #446 taught the tab strip and the rich rail rows to say so; both home screens
// still said "idle" beside nothing running. `_mobileOverviewExit()`
// (mobile-overview.js) is now the one rule all three surfaces read. It changes
// what a row SHOWS and leaves its `state` alone, because `state` picks the
// section and the sort order.
//
// `paneExitLabel()` is lifted out of the shipped app.js rather than restated
// here, so a change to what counts as "exited" there reaches these tests.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
/** The shipped `paneExitLabel()` from app.js, as source text. */
function paneExitLabelSource(): string {
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const match = appJs.match(/function paneExitLabel\(paneExit\) \{[\s\S]*?\n\}\n/);
if (!match) throw new Error('paneExitLabel() not found in app.js');
return match[0];
}
function fakeElement(): any {
const el: any = {
className: '',
type: '',
title: '',
textContent: '',
dataset: {},
style: {},
children: [] as any[],
setAttribute() {},
appendChild(child: any) {
el.children.push(child);
return child;
},
};
return el;
}
/** Every className in a fake-DOM subtree, depth first. */
function classNames(el: any): string[] {
return [el.className, ...(el.children || []).flatMap(classNames)].filter(Boolean);
}
function loadApp({ withPaneExitLabel = true } = {}) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: { innerWidth: 1512 },
document: {
documentElement: { getAttribute: () => null },
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'desktop' },
});
if (withPaneExitLabel) vm.runInContext(paneExitLabelSource(), context, { filename: 'app.js' });
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
app._shortenHomePath = (p: string) => p || '';
app.loadAppSettingsFromStorage = () => ({});
app.cases = [];
app.pendingHooks = new Map();
return app;
}
const EXIT_AT = 1_700_000_000_000;
function sessions(list: Array<Record<string, any>>) {
return new Map(list.map((s) => [s.id, { status: 'idle', mode: 'claude', workingDir: '/w', ...s }]));
}
describe('the desktop home rail', () => {
it('says "exited" for an idle session whose agent exited, and measures from the exit', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT }, lastActivityAt: 5 }]);
app.sessionOrder = ['gone'];
const [row] = app.buildHomeSessionRows();
expect(row.state).toBe('idle');
expect(row.display).toBe('exited');
expect(row.pill).toBe('exited');
expect(row.since).toEqual({ key: 'exited', at: EXIT_AT });
});
it('draws a neutral row: no idle or working class on the row, the dot or the pill', () => {
// A pane whose agent died mid-turn still has `status: 'busy'`, so without
// the display class its dot would pulse green beside "exited".
const app = loadApp();
app.sessions = sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]);
app.sessionOrder = ['gone'];
const [row] = app.buildHomeSessionRows();
expect(row.state).toBe('working');
const classes = classNames(app._buildHomeSessionRow(row));
expect(classes).toEqual(
expect.arrayContaining([
'home-sessions-row home-sessions-row--exited',
'home-sessions-dot home-sessions-dot--exited',
'home-sessions-pill home-sessions-pill--exited',
])
);
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
});
it('lets a pending permission prompt win over the exit', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'blocked', paneExit: { status: 0, at: EXIT_AT } }]);
app.sessionOrder = ['blocked'];
app.pendingHooks = new Map([['blocked', new Set(['permission_prompt'])]]);
const [row] = app.buildHomeSessionRows();
expect(row.display).toBe('needs');
expect(row.pill).toBe('needs you');
});
it('leaves a session without an exit exactly as it was', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'live', lastActivityAt: 5 }]);
app.sessionOrder = ['live'];
const [row] = app.buildHomeSessionRows();
expect(row.display).toBe('idle');
expect(row.pill).toBe('idle');
expect(row.since).toEqual({ key: 'idle', at: 5 });
});
it('degrades to no override when a stale cached app.js lacks paneExitLabel', () => {
const app = loadApp({ withPaneExitLabel: false });
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT } }]);
app.sessionOrder = ['gone'];
expect(app.buildHomeSessionRows()[0].pill).toBe('idle');
});
});
describe('the phone overview', () => {
it('says "exited" and keeps the row in its section', () => {
const app = loadApp();
const model = app.buildMobileOverviewModel({
sessions: sessions([
{ id: 'gone', paneExit: { status: 137, at: EXIT_AT } },
{ id: 'live', lastActivityAt: 5 },
]),
cases: [],
sessionOrder: ['gone', 'live'],
});
const byId = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
expect(byId.gone).toMatchObject({ state: 'idle', display: 'exited', pill: 'exited' });
expect(byId.gone.since).toEqual({ key: 'exited', at: EXIT_AT });
expect(byId.live).toMatchObject({ display: 'idle', pill: 'idle' });
});
it('draws a neutral row', () => {
const app = loadApp();
const model = app.buildMobileOverviewModel({
sessions: sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]),
cases: [],
});
app._pendingApprovalForSession = () => null;
const classes = classNames(app._buildMobileOverviewRow(model.current[0]));
expect(classes).toEqual(
expect.arrayContaining([
'mobile-overview-row mobile-overview-row--exited',
'mobile-overview-dot mobile-overview-dot--exited',
'mobile-overview-pill mobile-overview-pill--exited',
])
);
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
});
});
describe('the exited pill styles', () => {
it('gives both home screens a neutral exited pill', () => {
expect(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')).toMatch(/\.home-sessions-pill--exited \{/);
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(/\.mobile-overview-pill--exited \{/);
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* @fileoverview The clean-exit sweep (Ark0N/Codeman#446, part 2).
*
* A session whose agent the user ended with `/exit` is closed the way the X
* button closes it, so finished sessions stop piling up on the board. A crashed
* agent keeps its row, marked with the exit code.
*
* Each rule pinned here guards against a specific wrong close:
*
* 1. **Only an explicit numeric 0 is clean.** On tmux 3.2a a SIGKILLed pane
* reports neither a status nor a signal, so an absent status is the
* crashed-agent case, not the clean one.
* 2. **Two agreeing reads, not one.** A single reading can describe a pane
* that is about to be revived.
* 3. **Never while a start or attach is in flight.** The dead-pane respawn in
* `_setupOrAttachMuxSession()` revives an exited pane on purpose, and the
* pane reads as dead until it finishes.
* 4. **Only local mux-backed sessions.** A remote session's local pane is its
* ssh client, whose death may be a transport drop.
*
* Port: 3189
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { PaneExit, SessionRemote } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import {
CLEAN_EXIT_CLOSE_REASON,
CLEAN_EXIT_CONFIRMING_READS,
CLEAN_EXIT_MIN_PANE_LIFETIME_MS,
isCleanPaneExit,
shouldCloseCleanlyExitedSession,
} from '../src/pane-exit-sweep.js';
const PORT = 3189;
const AT = 1_700_000_000_000;
describe('isCleanPaneExit', () => {
it('accepts an explicit status of 0 with no signal', () => {
expect(isCleanPaneExit({ status: 0, at: AT })).toBe(true);
});
it('refuses an absent status, which is how a SIGKILL presents on tmux 3.2a', () => {
expect(isCleanPaneExit({ at: AT })).toBe(false);
});
it('refuses a non-zero status', () => {
expect(isCleanPaneExit({ status: 1, at: AT })).toBe(false);
expect(isCleanPaneExit({ status: 137, at: AT })).toBe(false);
});
it('refuses any reported signal, even beside a 0', () => {
expect(isCleanPaneExit({ signal: 9, at: AT })).toBe(false);
expect(isCleanPaneExit({ status: 0, signal: 15, at: AT })).toBe(false);
});
it('refuses an unknown exit', () => {
expect(isCleanPaneExit(undefined)).toBe(false);
});
});
describe('shouldCloseCleanlyExitedSession', () => {
const clean = {
paneExit: { status: 0, at: AT } as PaneExit,
confirmingReads: CLEAN_EXIT_CONFIRMING_READS,
paneLifecycleInFlight: false,
closing: false,
paneStartedAt: 0,
};
it('keeps a clean exit that lands within the startup window, as a startup failure', () => {
const justStarted = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS + 1;
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: justStarted, confirmingReads: 9 })).toBe(false);
// An exit read during the start itself is earlier than the stamp.
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: AT + 500, confirmingReads: 9 })).toBe(false);
});
it('closes a clean exit once the pane has outlived the startup window', () => {
const settled = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS;
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: settled })).toBe(true);
});
it('closes a clean exit that enough reads agreed on', () => {
expect(CLEAN_EXIT_CONFIRMING_READS).toBe(2);
expect(shouldCloseCleanlyExitedSession(clean)).toBe(true);
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 5 })).toBe(true);
});
it('waits for the second read', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 1 })).toBe(false);
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 0 })).toBe(false);
});
it('leaves a session alone while its pane is being started or revived', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, paneLifecycleInFlight: true })).toBe(false);
});
it('does not queue a second close for a session already closing', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, closing: true })).toBe(false);
});
it('keeps a crashed agent however many reads saw it', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { status: 137, at: AT }, confirmingReads: 9 })).toBe(
false
);
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { at: AT }, confirmingReads: 9 })).toBe(false);
});
});
describe('the sweep on a real server', () => {
// Drives the real `paneExitsUpdated` wiring: the mux reports a reading, the
// event fires, and the test checks whether the server closed the session.
let server: WebServer | null = null;
afterEach(async () => {
vi.restoreAllMocks();
await server?.stop?.();
server = null;
});
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
/** The mux members `Session` needs to be a local mux-backed session that can be stopped. */
const sessionMux = () =>
({
isAvailable: () => true,
killSession: async () => true,
}) as unknown as TerminalMultiplexer;
const addSession = (web: WebServer, extra: Record<string, unknown> = {}) => {
const muxName = `codeman-${Math.random().toString(16).slice(2, 10)}`;
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux: sessionMux(),
muxSession: { muxName, sessionId: 'x' } as unknown as MuxSession,
...extra,
});
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
return { session, muxName };
};
/** A server whose mux reports `exit` for every pane, seen by `reads` reads. */
const build = (exit: PaneExit | undefined, reads: number) => {
const web = new WebServer(PORT, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
const state = { exit, reads };
mux.getPaneExit = () => state.exit;
mux.getPaneExitReadCount = () => (state.exit ? state.reads : 0);
const tick = () => (mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
const cleanup = vi
.spyOn(web as unknown as { cleanupSession: (...a: unknown[]) => Promise<void> }, 'cleanupSession')
.mockResolvedValue(undefined);
return { web, state, tick, cleanup };
};
it('closes a cleanly exited session on the second read, with a reason in the lifecycle log', () => {
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 1);
const { session } = addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
// The exit is on the record already, so the tab says "exited (0)" meanwhile.
expect(session.paneExit).toEqual({ status: 0, at: AT });
state.reads = 2;
tick();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(session.id, true, CLEAN_EXIT_CLOSE_REASON);
});
it('keeps a session whose pane started moments before the clean exit', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
const { session } = addSession(web);
(session as unknown as { _paneStartedAt: number })._paneStartedAt = AT - 2_000;
tick();
expect(cleanup).not.toHaveBeenCalled();
expect(session.paneExit).toEqual({ status: 0, at: AT });
});
it('tries each exit once, so a failed close is not retried every tick', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
const { session } = addSession(web);
tick();
tick();
tick();
expect(cleanup).toHaveBeenCalledTimes(1);
// The spy resolved without removing the session, which is what a failed
// close looks like from here: the row stays, with its exit badge.
expect(session.paneExit).toEqual({ status: 0, at: AT });
});
it('gives a new exit in the same pane its own attempt', () => {
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 2);
addSession(web);
tick();
state.exit = { status: 0, at: AT + 60_000 };
tick();
expect(cleanup).toHaveBeenCalledTimes(2);
});
it('keeps a crashed agent on the board', () => {
const { web, tick, cleanup } = build({ status: 137, at: AT }, 5);
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('keeps an agent whose exit status tmux did not report', () => {
const { web, tick, cleanup } = build({ at: AT }, 5);
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('never closes a remote session, whose local pane is only the ssh client', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
addSession(web, { remote });
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('leaves a session alone while its pane is being revived', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
const { session } = addSession(web);
vi.spyOn(session, 'paneLifecycleInFlight', 'get').mockReturnValue(true);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('closes nothing when the mux cannot count its reads', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
delete (web as unknown as { mux: Record<string, unknown> }).mux.getPaneExitReadCount;
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('removes the session for real through cleanupSession', async () => {
// No spy on cleanupSession here: the close runs the same path as the X
// button, and the session leaves the server's map.
const web = new WebServer(PORT, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
mux.getPaneExit = () => ({ status: 0, at: AT });
mux.getPaneExitReadCount = () => 2;
const { session } = addSession(web);
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
await vi.waitFor(() => expect(sessions.has(session.id)).toBe(false));
});
});
describe('a session the server is closing', () => {
const session = () =>
new Session({
workingDir: '/tmp',
mode: 'shell',
useMux: true,
mux: { isAvailable: () => true } as unknown as TerminalMultiplexer,
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
});
it('refuses to start, so a racing start cannot orphan a tmux session', async () => {
const s = session();
s.markClosing(true);
await expect(s.startInteractive()).rejects.toThrow('Session is being closed');
await expect(s.startShell()).rejects.toThrow('Session is being closed');
});
it('is cleared again when the server gives the close up', async () => {
const web = new WebServer(PORT, false, true);
try {
const s = session();
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
sessions.set(s.id, s);
const internals = web as unknown as {
_doCleanupSession: (...a: unknown[]) => Promise<void>;
cleanupSession: (id: string, kill: boolean, reason: string) => Promise<void>;
};
let closingDuring = false;
vi.spyOn(internals, '_doCleanupSession').mockImplementation(async () => {
closingDuring = (s as unknown as { _closing: boolean })._closing;
throw new Error('layout prune unavailable');
});
await expect(internals.cleanupSession(s.id, true, 'test')).rejects.toThrow('layout prune unavailable');
expect(closingDuring).toBe(true);
expect((s as unknown as { _closing: boolean })._closing).toBe(false);
} finally {
vi.restoreAllMocks();
await web.stop();
}
});
});
describe('the pane lifecycle mark on Session', () => {
it('is raised for the whole of restartCli and lowered afterwards, even on failure', async () => {
let seenDuring: boolean | null = null;
let session: Session;
const mux = {
isAvailable: () => true,
muxSessionExists: () => true,
respawnPane: async () => {
seenDuring = session.paneLifecycleInFlight;
throw new Error('respawn failed');
},
clearPaneExit: () => {},
} as unknown as TerminalMultiplexer;
session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
});
expect(session.paneLifecycleInFlight).toBe(false);
await expect(session.restartCli()).rejects.toThrow('respawn failed');
expect(seenDuring).toBe(true);
expect(session.paneLifecycleInFlight).toBe(false);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview Deleting a session keeps `.claude-images` while a sibling in the
* same working directory is still live (Ark0N/Codeman#446).
*
* `cleanupSession()` removes `{workingDir}/.claude-images` recursively. That
* dir belongs to the working directory, not to the session, and several
* sessions routinely share one case directory, so closing one used to delete
* the pasted images a live sibling still referred to. The exited-agent sweep
* closes sessions unattended, which turns that from an occasional loss into a
* routine one.
*
* Port: 3188
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
const PORT = 3188;
describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set<string>();
const check = (
live: Array<{ id: string; workingDir: string }>,
opts: { persisted?: Array<{ id: string; workingDir: string; status?: string }>; killing?: Set<string> } = {}
) =>
pasteImageDirInUseByOtherSession({
live,
persisted: opts.persisted ?? [],
closingId: 'a',
workingDir: '/work/case',
killing: opts.killing ?? none,
});
it('finds a live sibling in the same working directory', () => {
expect(
check([
{ id: 'a', workingDir: '/work/case' },
{ id: 'b', workingDir: '/work/case' },
])
).toBe(true);
});
it('ignores the session being closed', () => {
expect(check([{ id: 'a', workingDir: '/work/case' }])).toBe(false);
});
it('ignores sessions in other directories, including a subdirectory', () => {
expect(
check([
{ id: 'a', workingDir: '/work/case' },
{ id: 'b', workingDir: '/work/other' },
{ id: 'c', workingDir: '/work/case/sub' },
])
).toBe(false);
});
it('normalises a trailing slash and dot segments', () => {
expect(check([{ id: 'b', workingDir: '/work/x/../case/' }])).toBe(true);
});
it('does not count a sibling that is being killed too', () => {
// Two sessions of one case killed together must not each defer to the
// other, or neither removes the dir.
expect(check([{ id: 'b', workingDir: '/work/case' }], { killing: new Set(['a', 'b']) })).toBe(false);
});
it('counts a detached session, which left the map but still runs in tmux', () => {
// `DELETE ?killMux=false` removes the session from the server's map and
// keeps its persisted record and its pane.
expect(check([], { persisted: [{ id: 'b', workingDir: '/work/case', status: 'idle' }] })).toBe(true);
});
it('does not count a persisted record demoted to stopped, or the closing session', () => {
expect(
check([], {
persisted: [
{ id: 'a', workingDir: '/work/case', status: 'idle' },
{ id: 'b', workingDir: '/work/case', status: 'stopped' },
],
})
).toBe(false);
});
it('matches a sibling that reaches the same directory through a symlink', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-paste-link-'));
try {
const real = join(root, 'case');
mkdirSync(real);
symlinkSync(real, join(root, 'current'));
expect(
pasteImageDirInUseByOtherSession({
live: [{ id: 'b', workingDir: join(root, 'current') }],
persisted: [],
closingId: 'a',
workingDir: real,
killing: none,
})
).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('deleting a session that shares its working directory', () => {
let server: WebServer;
let workingDir: string;
const base = `http://localhost:${PORT}`;
beforeAll(async () => {
workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-'));
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server.stop();
rmSync(workingDir, { recursive: true, force: true });
}, 60000);
const create = async (): Promise<string> => {
const res = await fetch(`${base}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir }),
});
const body = await res.json();
return body.data.session.id as string;
};
const remove = (id: string) => fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' });
it('keeps the images while a sibling is live, and removes them with the last session', async () => {
const first = await create();
const second = await create();
const imageDir = join(workingDir, '.claude-images');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-1.png'), 'x');
expect((await remove(first)).status).toBe(200);
expect(existsSync(join(imageDir, 'paste-1.png'))).toBe(true);
expect((await remove(second)).status).toBe(200);
expect(existsSync(imageDir)).toBe(false);
});
it('keeps the images while a sibling is only detached, since it still runs in tmux', async () => {
const detached = await create();
const deleted = await create();
// Persisting a new record is debounced, and a detach cancels the pending
// write, so wait for the record a long-running session would already have.
const store = (server as unknown as { store: { getSession: (id: string) => unknown } }).store;
await vi.waitFor(() => expect(store.getSession(detached)).toBeTruthy(), { timeout: 10_000 });
const imageDir = join(workingDir, '.claude-images');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-2.png'), 'x');
expect((await fetch(`${base}/api/sessions/${detached}?killMux=false`, { method: 'DELETE' })).status).toBe(200);
expect((await remove(deleted)).status).toBe(200);
expect(existsSync(join(imageDir, 'paste-2.png'))).toBe(true);
});
});
+19
View File
@@ -108,6 +108,25 @@ describe('which dead sessions may be rebuilt', () => {
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
});
it('never revives a session whose agent the user ended with a clean exit (#446)', () => {
// The clean-exit sweep would have closed it, had the power not gone first.
const persisted = { exited: persistedSession({ id: 'exited', paneExit: { status: 0, at: NOW - HOUR } }) };
const plan = planRebootRestore(['exited'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'agent-exited' }]);
});
it('still offers a crashed agent, and one whose exit status tmux never reported', () => {
// Same explicit-0 rule as the sweep: an absent status is unknown, not clean,
// and a crash keeps its row on the board, so it stays eligible here too.
const persisted = {
crashed: persistedSession({ id: 'crashed', paneExit: { status: 137, at: NOW - HOUR } }),
killed: persistedSession({ id: 'killed', paneExit: { at: NOW - HOUR } }),
};
const plan = planRebootRestore(['crashed', 'killed'], persisted, () => true);
expect(plan.restore.map((s) => s.sessionId)).toEqual(['crashed', 'killed']);
});
it('never revives a pane whose PTY-exit breaker had tripped', () => {
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
+7 -3
View File
@@ -163,9 +163,12 @@ describe('the rich row pill of an exited session', () => {
// which reads `status` and knows nothing about the exit, so without an override
// the muted dot sat beside a pill saying "idle".
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const fn = (re: RegExp, name: string) => {
const m = appJs.match(re)?.[0];
if (!m) throw new Error(`${name} not found in app.js`);
// The exit rule itself is shared with both home screens and lives in
// mobile-overview.js, so it is lifted from there rather than stubbed.
const overviewJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-overview.js'), 'utf8');
const fn = (re: RegExp, name: string, source = appJs) => {
const m = source.match(re)?.[0];
if (!m) throw new Error(`${name} not found`);
return m;
};
type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null };
@@ -174,6 +177,7 @@ describe('the rich row pill of an exited session', () => {
return {
${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')},
${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')},
${fn(/ {2}_mobileOverviewExit\(state, session\) \{[\s\S]*?\n {2}\}/, '_mobileOverviewExit', overviewJs)},
_mobileOverviewState(session, hooks) {
if (hooks && hooks.has('permission_prompt')) return 'needs';
if (hooks && hooks.has('idle_prompt')) return 'waiting';
+45
View File
@@ -1126,6 +1126,37 @@ describe('TmuxManager pane-exit bookkeeping', () => {
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.clearPaneExit('codeman-aaaa');
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
});
// The clean-exit sweep closes a session only once two reads agreed on its
// exit (Ark0N/Codeman#446), so the count must rise only on an exact repeat.
it('counts the reads that agreed on one exit', () => {
const manager = new TmuxManager();
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(2);
});
it('starts the count again when the status or the pane pid changes', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|1|'), NOW + 4000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|1|'), NOW + 6000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
});
it('drops the count once a read sees the pane alive again', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 4000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
});
});
@@ -1214,6 +1245,20 @@ describe('the pane-exit watcher tick', () => {
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
});
it('does not count an empty read as confirming an exit', async () => {
// The clean-exit sweep closes on the second agreeing read. A read tmux did
// not answer agrees with nothing, so it must not supply that second read.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW);
manager.rows = [];
await manager.refreshPaneExits(NOW + 2000);
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(1);
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW + 4000);
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(2);
});
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.