mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
* 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>
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|