mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(session): merge-time fixes for the dead-pane resume pin (#467)
- test/setup.ts strips CLAUDE_CONFIG_DIR (pinned in test-env-isolation), so transcript-fixture tests such as session-custom-model-restart no longer go red on a machine that exports it for a separate Claude account (#255). - The vanished-tmux-session branch of _setupOrAttachMuxSession() relaunches the CLI through createSession() just like a failed respawn, so it now takes the same resume pin. A genuinely new session is unaffected. - After a dead-pane respawn of a fallback-chain CLI, _claudeSessionId names the conversation the walk actually pinned instead of the chain tail, which the walk may have passed over for lack of a transcript. - _claudeConfigDir() trims the override like claudeProjectsDir() does. - The remote-reattach test is labelled as documentation, since the pin builder's own remote guard would make it pass either way. - CLAUDE.md: the create-path pin persists through toState() as resumeSessionId, and the end of the walk adds no pin rather than clearing the launch seed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,32 @@ function failingRespawnMux() {
|
||||
return { mux: mux as unknown as TerminalMultiplexer, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* A mux whose tmux lost the WHOLE session (tmux kill-server, a crash, an
|
||||
* external kill-session), not just the pane. `_setupOrAttachMuxSession()` drops
|
||||
* its stale handle and goes straight to `createSession()`, relaunching the CLI
|
||||
* exactly as the failed-respawn fallback does.
|
||||
*/
|
||||
function vanishedSessionMux() {
|
||||
const calls: CreateSessionOptions[] = [];
|
||||
const respawns: RespawnPaneOptions[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
muxSessionExists: () => false,
|
||||
isPaneDead: () => false,
|
||||
setAttached: () => {},
|
||||
respawnPane: async (options: RespawnPaneOptions) => {
|
||||
respawns.push(options);
|
||||
return 4242;
|
||||
},
|
||||
createSession: async (options: CreateSessionOptions) => {
|
||||
calls.push(options);
|
||||
return muxSession('codeman-recreated');
|
||||
},
|
||||
};
|
||||
return { mux: mux as unknown as TerminalMultiplexer, calls, respawns };
|
||||
}
|
||||
|
||||
const CONVERSATION = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
|
||||
|
||||
let configDir: string;
|
||||
@@ -258,6 +284,58 @@ describe('pinning a conversation onto a relaunch', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the create path when tmux lost the whole session, not just the pane', async () => {
|
||||
// The stale-session branch nulls the handle and never sets the failed-respawn
|
||||
// flag, so without its own pin the relaunch carried the bare launch line and
|
||||
// met the same `--session-id ... already in use` refusal.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls, respawns } = vanishedSessionMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(respawns).toHaveLength(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].resumeSessionId).toBe(CONVERSATION);
|
||||
expect(session.claudeSessionId).toBe(CONVERSATION);
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a genuinely new session unpinned on the create path', async () => {
|
||||
// No mux handle to begin with, so the stale-session flag is never set and
|
||||
// the create options keep their original shape.
|
||||
const { mux, calls } = vanishedSessionMux();
|
||||
const session = localSession({ muxSession: undefined }, mux);
|
||||
giveTranscript(session.id);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
} finally {
|
||||
await session.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('names the conversation the dead-pane respawn actually resumed', async () => {
|
||||
// The chain tail has no transcript, so the walk degrades to the session id.
|
||||
// The session must then report that id, not the chain tail claude never
|
||||
// opened: the response viewer, Read My Mind and the alias map all read it.
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession({ claudeSessionChain: [CONVERSATION] }, mux);
|
||||
giveTranscript(session.id);
|
||||
|
||||
await session.startInteractive();
|
||||
try {
|
||||
expect(calls[0].resumeSessionId).toBe(session.id);
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
} 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
|
||||
@@ -311,9 +389,13 @@ describe('pinning a conversation onto a relaunch', () => {
|
||||
expect(calls[0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins nothing for a remote reattach, which relaunches no CLI', async () => {
|
||||
it('documents that a remote reattach carries no pin', async () => {
|
||||
// `reattachRemote()` re-runs the remote session command, which attaches to
|
||||
// the durable remote tmux with the agent still running inside it.
|
||||
// ⚠️ Documentation, not a regression guard: `reattachRemote()` only runs for
|
||||
// a remote session, and the pin builder refuses remote sessions on its own,
|
||||
// so this would still pass if `reattachRemote()` were switched to the pinned
|
||||
// builder. The builder's remote guard is what the test above pins.
|
||||
giveTranscript(CONVERSATION);
|
||||
const { mux, calls } = recordingMux();
|
||||
const session = localSession(
|
||||
|
||||
@@ -45,6 +45,12 @@ delete process.env.CODEMAN_GESTURE;
|
||||
// operator who exports it (exactly who the feature is for) would otherwise see the
|
||||
// root-install byte-identity assertions fail.
|
||||
delete process.env.CODEMAN_BASE_URL;
|
||||
// CLAUDE_CONFIG_DIR (#255) relocates Claude's whole tree, transcripts included, and
|
||||
// `claudeProjectsDir()` reads it before it ever looks at `homedir()`. A developer who runs
|
||||
// Codeman against a separate Claude account exports exactly this, and every test that writes
|
||||
// a transcript fixture under the temp HOME's `~/.claude/projects` then reads "no transcript"
|
||||
// (found at merge of #467: test/session-custom-model-restart.test.ts went red).
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
// Instance selection is PROCESS-WIDE and is what `src/config/instance.ts` derives
|
||||
// both the data dir and the tmux socket from, so a shell that exports any of these
|
||||
|
||||
@@ -34,6 +34,7 @@ const STRIPPED_ENV_VARS: Array<[name: string, why: string]> = [
|
||||
['CODEMAN_INSTANCE', 'moves the data dir to ~/.codeman-<name> and the tmux socket to codeman-<name>'],
|
||||
['CODEMAN_DATA_DIR', 'ABSOLUTE override: bypasses the temp HOME and points the suite at a real data dir'],
|
||||
['CODEMAN_TMUX_SOCKET', 'renames the socket resolveTmuxSocketName() returns'],
|
||||
['CLAUDE_CONFIG_DIR', 'relocates the Claude tree, so transcript fixtures under the temp HOME read as missing'],
|
||||
];
|
||||
|
||||
const SETUP_SOURCE = readFileSync(fileURLToPath(new URL('./setup.ts', import.meta.url)), 'utf-8');
|
||||
|
||||
Reference in New Issue
Block a user