mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #467 from irisitymichaelgrundberg/fix/respawn-session-id-collision
fix(session): resume the conversation when respawning a dead pane
This commit is contained in:
+128
-17
@@ -62,6 +62,8 @@ import {
|
||||
type SessionWriteOptions,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import { probeRemoteCliVersion } from './remote-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
@@ -1749,7 +1751,7 @@ export class Session extends EventEmitter {
|
||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
@@ -1765,7 +1767,28 @@ export class Session extends EventEmitter {
|
||||
if (isRestored) {
|
||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||
} else {
|
||||
// Create a new mux session
|
||||
// Create a new mux session. When this is the FALLBACK after a failed
|
||||
// respawn, the eagerly-built create options still carry the unpinned
|
||||
// launch seed, so a session whose transcript exists would meet the same
|
||||
// `--session-id ... already in use` refusal the respawn just lost to —
|
||||
// the recovery of last resort failing for the very reason it was needed.
|
||||
// A genuinely new session has no transcript under any of its candidate
|
||||
// ids, so nothing is pinned and its command shape is unchanged.
|
||||
//
|
||||
// `_resumeSessionId` is written alongside, not just the create options:
|
||||
// this branch leaves `isRestored` false, so the block that sets
|
||||
// `_claudeSessionId` below reads that field and would otherwise settle on
|
||||
// `this.id` while the CLI resumes the chain tail. The response viewer,
|
||||
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
|
||||
// until the next first-hand hook, so the two have to name the same
|
||||
// conversation.
|
||||
if (needsNewSession) {
|
||||
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
|
||||
if (pinned) {
|
||||
options.createSessionOptions.resumeSessionId = pinned;
|
||||
this._resumeSessionId = pinned;
|
||||
}
|
||||
}
|
||||
this._muxSession = await mux.createSession(options.createSessionOptions);
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
@@ -1878,21 +1901,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
this._pinOmpRespawnId();
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
||||
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
||||
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
||||
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
||||
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
||||
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
||||
// already use, so pin the live conversation id for THIS respawn only. The registry
|
||||
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
||||
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
||||
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
||||
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
||||
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
||||
}
|
||||
const newPid = await mux.respawnPane(options);
|
||||
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
||||
if (!newPid) {
|
||||
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
@@ -1947,6 +1956,108 @@ export class Session extends EventEmitter {
|
||||
return this._withCustomModelLaunchModel(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respawn options for a pane whose command is being REPLACED, with the
|
||||
* conversation pinned so the relaunch resumes rather than collides.
|
||||
*
|
||||
* A CLI that launches with `--session-id <id>` refuses an id that is already
|
||||
* in use (claude: `Error: Session ID ... is already in use.`), and every
|
||||
* session whose agent has been prompted owns a transcript under that id. So
|
||||
* relaunching such a pane with the bare launch line fails, the pane dies
|
||||
* again immediately, and the user's conversation is stranded. A launch that
|
||||
* declares a `fallback` chain renders `resume || new` once a resume id is
|
||||
* set, which is the shape that survives both cases.
|
||||
*
|
||||
* Three candidates are tried in priority order — the conversation chain's
|
||||
* tail, the launch seed, then the session's own id — and the first one a
|
||||
* transcript backs is pinned. Four conditions gate that walk, each protecting
|
||||
* against a way of resuming the WRONG conversation or of making a working
|
||||
* relaunch fail.
|
||||
*
|
||||
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
|
||||
* whose route refuses both, the dead-pane respawn is reached by every session
|
||||
* shape. Their pane commands (`buildRemoteLaunchCommand`,
|
||||
* `claudeDockerPaneCommand`) already render a SELF-HEALING
|
||||
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
|
||||
* moment the resume id differs from the session id. The conversation lives on
|
||||
* the far side, so a local id pinned onto it resolves to nothing there, the
|
||||
* resume fails, and the `--session-id` fallback then collides with the
|
||||
* transcript the far side really does hold — both branches fail and the pane
|
||||
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
|
||||
*
|
||||
* ⚠️ **The candidates come from the conversation CHAIN, never from
|
||||
* `_claudeSessionId`.** That field holds either a first-hand id from the
|
||||
* CLI's own hook payload or a history correlation, which is a guess keyed on
|
||||
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
|
||||
* precisely so it cannot "write a foreign conversation into this pane's
|
||||
* permanent record", and launching from one would do worse than the display
|
||||
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
|
||||
* a conversation that was never this pane's. The chain's tail is the live
|
||||
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
|
||||
* seed, which is written once at construction and never moves off a `/clear`.
|
||||
*
|
||||
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
|
||||
* included, and a candidate that has none is passed over rather than ending
|
||||
* the walk.** A pin that differs from the session id leaves
|
||||
* `--session-id <this.id>` in the fallback branch, so a resume that finds
|
||||
* nothing collides there and the pane dies exactly as it did before this
|
||||
* pinning existed. Pinning `this.id` renders the self-healing
|
||||
* `--resume <id> || --session-id <id>`, which is correct whether or not a
|
||||
* transcript exists, but a pane that has none pays for the shape twice:
|
||||
* claude prints "No conversation found" into the scrollback of a session that
|
||||
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
|
||||
* rendered `a || b`, so the branch that actually runs loses its priority for
|
||||
* the life of the session. Falling off the end of the walk therefore pins
|
||||
* nothing, which is the right answer: with no transcript anywhere there is
|
||||
* nothing for the bare `--session-id <this.id>` to collide with.
|
||||
*
|
||||
* The create route pre-validates a resume id for the same reason, though it
|
||||
* additionally requires the transcript be substantial — here mere existence
|
||||
* is the question, because a one-line transcript still makes `--session-id`
|
||||
* collide.
|
||||
*
|
||||
* The registry shape is the last gate, not the CLI's name: an entry whose
|
||||
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
|
||||
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
|
||||
*
|
||||
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
|
||||
* session command, which attaches to the durable remote tmux with the agent
|
||||
* still inside it and renders no local `--session-id` to collide.
|
||||
*/
|
||||
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
|
||||
const options = this._buildRespawnPaneOptions();
|
||||
if (this._remote || this._docker) return options;
|
||||
const entry = getCli(this.mode);
|
||||
if (entry?.launch.chain !== 'fallback') return options;
|
||||
|
||||
const resumeIdPattern = entry.launch.params?.resumeId;
|
||||
const configDir = this._claudeConfigDir();
|
||||
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
||||
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
|
||||
for (const candidate of candidates) {
|
||||
// A session Codeman DISCOVERED on the socket rather than created carries a
|
||||
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
|
||||
// pattern. The renderer would silently drop the resume flag and emit the
|
||||
// unpinned command, so say so here rather than letting the caller believe
|
||||
// the pane was pinned.
|
||||
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
|
||||
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
|
||||
continue;
|
||||
}
|
||||
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
|
||||
options.resumeSessionId = candidate;
|
||||
return options;
|
||||
}
|
||||
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
|
||||
// unpinned options already carry is the correct command.
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
return this._envOverrides?.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
||||
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Does a Claude conversation transcript exist on this host?
|
||||
*
|
||||
* Claude writes one `<conversation-id>.jsonl` per conversation under
|
||||
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
|
||||
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
|
||||
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
|
||||
*
|
||||
* The project directory name is derived from the working directory, and a case
|
||||
* that has been moved or renamed leaves its transcript under the OLD name, so
|
||||
* the search is across every project directory rather than the one that matches
|
||||
* the pane's cwd today.
|
||||
*
|
||||
* ⚠️ Existence is the whole question here, with no size floor. The create route
|
||||
* additionally requires ~4 KB before it will resume, which is a "is this
|
||||
* conversation worth resuming" judgement; for a relaunch the question is the
|
||||
* opposite one — a one-line transcript still makes `--session-id` collide.
|
||||
*
|
||||
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
|
||||
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
|
||||
* that id either, so a lookup that misses the real config dir turns a
|
||||
* recoverable pane into the collision this module exists to prevent.
|
||||
*
|
||||
* @dependencies none
|
||||
* @consumedby session (relaunch resume pinning)
|
||||
*
|
||||
* @module utils/claude-transcript
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
|
||||
* (#255) and, failing that, the server process's own.
|
||||
*
|
||||
* ⚠️ The process env is not optional here. A pane inherits the server's
|
||||
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
|
||||
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
|
||||
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
|
||||
* (claude-credentials.ts) and `realClaudeConfigDir()`
|
||||
* (custom-model-injection-apply.ts) resolve the same directory the same way.
|
||||
*/
|
||||
export function claudeProjectsDir(configDir?: string): string {
|
||||
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
|
||||
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a transcript for `conversationId` exists under any project
|
||||
* directory. Returns false for a missing projects dir or an unreadable one,
|
||||
* which leaves the caller unpinned: safe where nothing else can collide with
|
||||
* the bare `--session-id`, and the reason the caller walks its candidates down
|
||||
* to the session's own id rather than treating one false answer as final.
|
||||
*/
|
||||
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
|
||||
if (!conversationId) return false;
|
||||
const projectsDir = claudeProjectsDir(configDir);
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = await readdir(projectsDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const projectDir of projectDirs) {
|
||||
try {
|
||||
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
|
||||
return true;
|
||||
} catch {
|
||||
// Not in this project directory; keep looking.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @fileoverview Relaunching a pane must resume its conversation, not collide
|
||||
* with it, and must not resume somebody else's.
|
||||
*
|
||||
* A CLI that launches with `--session-id <id>` refuses an id that is already in
|
||||
* use (claude: `Error: Session ID ... is already in use.`), and every session
|
||||
* whose agent has been prompted owns a transcript under that id. A relaunch
|
||||
* that passes the bare launch line therefore dies on startup, the pane goes
|
||||
* dead again at once, and the conversation is stranded.
|
||||
*
|
||||
* `restartCli()` has pinned a resume id for this reason since the custom-model
|
||||
* work. The dead-pane respawn in `_setupOrAttachMuxSession()` did not, and its
|
||||
* comment said so explicitly — "Unlike the dead-pane respawn, this one kills a
|
||||
* WORKING pane whose conversation already has a transcript". That assumption is
|
||||
* what these tests refute: a pane whose agent exited has a transcript too.
|
||||
*
|
||||
* The pin walks three candidates — the conversation chain's tail, the launch
|
||||
* seed, then the session's own id — and takes the first one a transcript backs.
|
||||
* Most of these tests are about the four gates on that walk rather than about
|
||||
* the pin, because each gate stands for a way of resuming the WRONG
|
||||
* conversation or of making a working relaunch fail. Two more are about what
|
||||
* the walk does when a candidate misses: it carries on to the next, and pinning
|
||||
* nothing is the right answer only once every candidate has missed.
|
||||
*
|
||||
* Port: N/A
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
|
||||
import { claudeTranscriptExists } from '../src/utils/claude-transcript.js';
|
||||
import type {
|
||||
CreateSessionOptions,
|
||||
MuxSession,
|
||||
RespawnPaneOptions,
|
||||
TerminalMultiplexer,
|
||||
} from '../src/mux-interface.js';
|
||||
|
||||
/** Captures the options each respawn is invoked with. */
|
||||
function recordingMux() {
|
||||
const calls: RespawnPaneOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
isPaneDead: () => true,
|
||||
// Called by the PTY-exit handler during teardown. Absent, it throws
|
||||
// asynchronously after the test body has already passed, which vitest
|
||||
// reports as an unhandled error rather than a failure.
|
||||
setAttached: () => {},
|
||||
respawnPane: async (options: RespawnPaneOptions) => {
|
||||
calls.push(options);
|
||||
return 4242;
|
||||
},
|
||||
};
|
||||
return { mux: mux as unknown as TerminalMultiplexer, calls };
|
||||
}
|
||||
|
||||
const muxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
|
||||
|
||||
/**
|
||||
* A mux whose `respawnPane` fails, which is what sends
|
||||
* `_setupOrAttachMuxSession()` down its create-a-new-session fallback — the
|
||||
* path that has to pin too, since it meets the same refusal the respawn just
|
||||
* lost to.
|
||||
*/
|
||||
function failingRespawnMux() {
|
||||
const calls: CreateSessionOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => true,
|
||||
isPaneDead: () => true,
|
||||
setAttached: () => {},
|
||||
respawnPane: async () => 0,
|
||||
createSession: async (options: CreateSessionOptions) => {
|
||||
calls.push(options);
|
||||
return muxSession('codeman-recreated');
|
||||
},
|
||||
};
|
||||
return { mux: mux as unknown as TerminalMultiplexer, calls };
|
||||
}
|
||||
|
||||
const CONVERSATION = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
|
||||
|
||||
let configDir: string;
|
||||
|
||||
/** A relocated Claude config dir, so the transcript gate reads a real fixture. */
|
||||
beforeEach(() => {
|
||||
configDir = mkdtempSync(join(tmpdir(), 'codeman-transcript-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Write the `<id>.jsonl` Claude would have written for a conversation. */
|
||||
function giveTranscript(conversationId: string): void {
|
||||
const projectDir = join(configDir, 'projects', '-tmp-case');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, `${conversationId}.jsonl`), '{"type":"user"}\n');
|
||||
}
|
||||
|
||||
function localSession(extra: Record<string, unknown> = {}, mux?: TerminalMultiplexer) {
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: true,
|
||||
mux,
|
||||
muxSession: muxSession(),
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: configDir },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe('pinning a conversation onto a relaunch', () => {
|
||||
it('pins the chain tail on the DEAD-PANE respawn, which is the bug', async () => {
|
||||
// The path a recovered `/exit`ed session takes, and the one that was
|
||||
// missing the pin. Driven through `startInteractive()` rather than asserted
|
||||
// from source: a comment claiming a thing happens is exactly what was wrong
|
||||
// here before.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].resumeSessionId).toBe(CONVERSATION);
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the chain tail on a custom-model restart too', async () => {
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBe(CONVERSATION);
|
||||
});
|
||||
|
||||
it('prefers the live chain tail over the launch seed', async () => {
|
||||
// `_resumeSessionId` is written once at construction and never moves, so a
|
||||
// `/clear` after launch leaves it pointing at the predecessor. Resuming
|
||||
// that would reopen an abandoned conversation and strand the live one.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession(
|
||||
{ resumeSessionId: 'bbbbcccc-dddd-eeee-ffff-000011112222', claudeSessionChain: [CONVERSATION] },
|
||||
mux
|
||||
);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBe(CONVERSATION);
|
||||
});
|
||||
|
||||
it('falls back to the launch seed when no conversation was ever recorded', async () => {
|
||||
const seed = 'bbbbcccc-dddd-eeee-ffff-000011112222';
|
||||
giveTranscript(seed);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ resumeSessionId: seed }, mux);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBe(seed);
|
||||
});
|
||||
|
||||
it('never resumes an id the conversation chain did not vouch for', async () => {
|
||||
// `_claudeSessionId` also holds history-CORRELATED guesses, keyed on the
|
||||
// working directory, which the chain deliberately refuses. Launching from
|
||||
// one would open and WRITE to a conversation that was never this pane's —
|
||||
// worse than the display bug that rule exists to prevent.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({}, mux);
|
||||
// Backed, so the walk reaching it pins it. Without this the session would
|
||||
// land unpinned for want of a transcript rather than for refusing the
|
||||
// guess, and the test would pass while proving nothing.
|
||||
giveTranscript(session.id);
|
||||
session.adoptClaudeSessionId(CONVERSATION); // no firstHand flag: a guess
|
||||
expect(session.claudeSessionId).toBe(CONVERSATION);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).not.toBe(CONVERSATION);
|
||||
expect(calls[0].resumeSessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('degrades to the session id rather than to the colliding bare command', async () => {
|
||||
// The chain tail is gone from disk but the session's own id is not, which
|
||||
// is every session prompted before its first `/clear`. Dropping the pin
|
||||
// outright hands back `--session-id <this.id>` alone — the very refusal
|
||||
// this whole mechanism removes — so the walk carries on to the next
|
||||
// candidate instead of stopping at the first miss.
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
giveTranscript(session.id);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('pins nothing at all when no candidate has a transcript', async () => {
|
||||
// Falling off the end of the walk is the one case where the bare
|
||||
// `--session-id <this.id>` is right: nothing on disk can collide with it,
|
||||
// and pinning anyway would cost a brand-new pane claude's "No conversation
|
||||
// found" line plus the `nice` priority on the branch that actually runs.
|
||||
// The walk only ever ADDS a pin, so a session launched as a resume keeps
|
||||
// the seed its options already carried — see the custom-model restart
|
||||
// tests, which cover that case.
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({}, mux);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins the create-path fallback after a failed respawn', async () => {
|
||||
// The recovery of last resort would otherwise meet the same refusal that
|
||||
// made it the fallback, since the create options were built eagerly from
|
||||
// the unpinned launch seed.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = failingRespawnMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].resumeSessionId).toBe(CONVERSATION);
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the session naming the conversation the create path resumed', async () => {
|
||||
// That path leaves `isRestored` false, so `_claudeSessionId` is recomputed
|
||||
// from the launch fields and lands on `this.id` unless the pin is written
|
||||
// back to `_resumeSessionId` as well. The response viewer, Read My Mind and
|
||||
// the unified-list alias map read that field until the next first-hand
|
||||
// hook, so a mismatch points all three at a conversation claude never
|
||||
// opened.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux } = failingRespawnMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(session.claudeSessionId).toBe(CONVERSATION);
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins nothing for a remote session, whose conversation lives elsewhere', async () => {
|
||||
// The dead-pane respawn is reached by every session shape, unlike
|
||||
// `restartCli()` whose route refuses remote. A local id pinned onto a
|
||||
// remote pane resolves to nothing there, and the `--session-id` fallback
|
||||
// then collides with the transcript the remote host really does hold.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession(
|
||||
{
|
||||
remote: { hostId: 'h1', label: 'box', host: 'box', username: 'dev', remotePath: '/tmp' },
|
||||
claudeSessionChain: [CONVERSATION],
|
||||
},
|
||||
mux
|
||||
);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins nothing for a docker case, whose pane execs into the container', async () => {
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession(
|
||||
{
|
||||
docker: { hostId: 'd1', label: 'ctr', containerName: 'ctr' },
|
||||
claudeSessionChain: [CONVERSATION],
|
||||
},
|
||||
mux
|
||||
);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins nothing for a CLI that mints its own resume id', async () => {
|
||||
// codex/pi/omp/grok declare no `fallback` chain and read their resume id
|
||||
// from their own config, so a top-level pin would be meaningless at best.
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ mode: 'codex' }, mux);
|
||||
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins nothing for a remote reattach, which relaunches no CLI', async () => {
|
||||
// `reattachRemote()` re-runs the remote session command, which attaches to
|
||||
// the durable remote tmux with the agent still running inside it.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession(
|
||||
{
|
||||
remote: { hostId: 'h1', label: 'box', host: 'box', username: 'dev', remotePath: '/tmp' },
|
||||
claudeSessionChain: [CONVERSATION],
|
||||
},
|
||||
mux
|
||||
);
|
||||
|
||||
expect(await session.reattachRemote()).toBe(true);
|
||||
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the pin renders', () => {
|
||||
// The rendered command is what actually runs, and it is where each gate's
|
||||
// reason shows. Asserting here rather than counting call sites in the source
|
||||
// is what would have caught the divergent-pin and synthetic-id cases.
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
const entry = getCli('claude');
|
||||
const render = (resumeSessionId?: string) => {
|
||||
if (!entry) throw new Error('no registry entry for claude');
|
||||
return buildSpawnCommandFromRegistry(entry, {
|
||||
mode: 'claude',
|
||||
sessionId: SID,
|
||||
claudeCliVersion: null,
|
||||
resumeSessionId,
|
||||
});
|
||||
};
|
||||
|
||||
it('renders the colliding bare form with no pin — the bug itself', () => {
|
||||
expect(render()).toContain(`--session-id "${SID}"`);
|
||||
expect(render()).not.toContain('--resume');
|
||||
});
|
||||
|
||||
it('renders a self-healing resume-or-new when the pin is the session id', () => {
|
||||
expect(render(SID)).toBe(
|
||||
`claude --dangerously-skip-permissions --resume "${SID}" || claude --dangerously-skip-permissions --session-id "${SID}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the SESSION id in the fallback branch when the pin diverges', () => {
|
||||
// Which is why a pin with no transcript behind it has to be dropped: the
|
||||
// fallback is the colliding form, so a failed resume dies twice.
|
||||
expect(render(CONVERSATION)).toContain(`--resume "${CONVERSATION}"`);
|
||||
expect(render(CONVERSATION)).toContain(`--session-id "${SID}"`);
|
||||
});
|
||||
|
||||
it('drops a synthetic discovered id, which fails the uuid token pattern', () => {
|
||||
// `reconcileSessions()` mints `restored-<fragment>` for a tmux session
|
||||
// Codeman found but does not own. The renderer emits the unpinned command,
|
||||
// so those panes keep the pre-existing behaviour.
|
||||
expect(render('restored-40568a29')).not.toContain('--resume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('where the transcript lookup reads', () => {
|
||||
it("honours the server process's own CLAUDE_CONFIG_DIR", async () => {
|
||||
// A pane inherits the server environment through tmux, so on an install
|
||||
// that exports this the CLI writes its transcripts there. Reading `~/.claude`
|
||||
// regardless answers "no transcript" for every conversation on the host,
|
||||
// and under the walk above that means the colliding bare command.
|
||||
giveTranscript(CONVERSATION);
|
||||
const before = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
try {
|
||||
expect(await claudeTranscriptExists(CONVERSATION)).toBe(true);
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = before;
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the session's own relocated dir over the process one", async () => {
|
||||
// A session pointed at a separate Claude account (#255) reads its own tree,
|
||||
// not the server's.
|
||||
giveTranscript(CONVERSATION);
|
||||
const before = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = join(configDir, 'nowhere');
|
||||
try {
|
||||
expect(await claudeTranscriptExists(CONVERSATION, configDir)).toBe(true);
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = before;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@
|
||||
* 2. Applying to a local claude session killed the pane: the relaunch was
|
||||
* `claude --session-id <id>` and Claude refuses an id that already has a
|
||||
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
|
||||
* docker and remote pane commands use, i.e. a pinned resume id.
|
||||
* docker and remote pane commands use, i.e. a pinned resume id. The pin is
|
||||
* gated on that transcript existing, so these tests write one.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
*
|
||||
@@ -20,7 +21,7 @@
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -30,13 +31,29 @@ import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, SessionMode } from '../src/types.js';
|
||||
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
|
||||
const projectsDir = join(homedir(), '.claude', 'projects', '-custom-model-restart');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
// Only the directory these tests create. `setup.ts` gives each test file a
|
||||
// temp HOME, but a wider sweep here would delete a real `~/.claude` the day
|
||||
// that stops being true.
|
||||
rmSync(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Write the transcript Claude would have written for a conversation. A pane
|
||||
* `restartCli()` relaunches is a WORKING one, so its conversation has a
|
||||
* transcript on disk; that is both what makes the bare `--session-id` collide
|
||||
* and what the pin is now gated on.
|
||||
*/
|
||||
function giveTranscript(conversationId: string): void {
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
writeFileSync(join(projectsDir, `${conversationId}.jsonl`), '{"type":"user"}\n');
|
||||
}
|
||||
|
||||
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const mux = new TmuxManager();
|
||||
@@ -118,6 +135,7 @@ describe('clearing a selection unsets what it injected', () => {
|
||||
describe('restartCli() must not kill a working pane', () => {
|
||||
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
|
||||
const { session, respawn } = liveSession('claude');
|
||||
giveTranscript(session.id);
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.resumeSessionId).toBe(session.claudeSessionId);
|
||||
@@ -126,7 +144,33 @@ describe('restartCli() must not kill a working pane', () => {
|
||||
expect(session.toState().resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('claude: pins nothing when the pane has no transcript, because nothing can collide', async () => {
|
||||
// A pane that was launched and never prompted. `--session-id <this.id>` is
|
||||
// accepted on an id no transcript holds, so pinning would buy nothing and
|
||||
// cost two things: claude prints "No conversation found" into a pane with
|
||||
// no history, and `wrapWithNice()` prefixes only the first branch of the
|
||||
// rendered `a || b`, so the branch that actually runs loses its priority
|
||||
// for the life of the session.
|
||||
const { session, respawn } = liveSession('claude');
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
|
||||
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||
giveTranscript(RESUMED);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
||||
});
|
||||
|
||||
it('claude: a launch seed survives the pin walk even with its transcript gone', async () => {
|
||||
// The walk only ever ADDS a pin. The seed is what the session was created
|
||||
// with and every respawn has always carried it, so a transcript deleted
|
||||
// under a running session leaves the relaunch on
|
||||
// `--resume <seed> || --session-id <this.id>` — the resume fails and the
|
||||
// fallback runs, which is safe precisely because nothing is on disk to
|
||||
// collide with.
|
||||
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||
await session.restartCli();
|
||||
|
||||
Reference in New Issue
Block a user