mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge pull request #362 from timkjr/feat/omp-remote-continuation
fix(omp,remote): thread remote-omp resume/continue through respawn and reattach
This commit is contained in:
@@ -93,6 +93,11 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
"tmux-manager.ts::mode === 'claude'":
|
||||
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string',
|
||||
"tmux-manager.ts::mode === 'omp'":
|
||||
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
|
||||
|
||||
// --- Per-CLI prose and launch handling not yet generalised ---
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'":
|
||||
|
||||
@@ -25,7 +25,7 @@ import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession } from '../src/types.js';
|
||||
import type { MuxSession, SessionRemote } from '../src/types.js';
|
||||
|
||||
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
|
||||
@@ -126,4 +126,48 @@ describe('OMP: fresh session vs. reattach must not share resumeSessionId resolut
|
||||
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
|
||||
expect(session.claudeSessionId).toBe('real-omp-uuid');
|
||||
});
|
||||
|
||||
it("a remote session never resolves --resume from this host's local ~/.omp, even when a same-named local session file exists", () => {
|
||||
// Seed a LOCAL session file whose directory mangle happens to match this
|
||||
// remote session's remotePath. If _pinOmpRespawnId() ever fell through to
|
||||
// resolveAndClaimOmpSessionId() for a remote session, it would wrongly
|
||||
// claim/pin this unrelated local conversation's id onto the remote respawn.
|
||||
seedOmpSessionFile('wrong-local-conversation-id');
|
||||
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'remote-box',
|
||||
label: 'remote-box',
|
||||
host: 'remote-box',
|
||||
username: 'someone',
|
||||
remotePath: workingDir,
|
||||
owned: true,
|
||||
};
|
||||
|
||||
const muxSession: MuxSession = {
|
||||
sessionId: 'placeholder',
|
||||
muxName: 'codeman-deadbeef',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
attached: false,
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'omp',
|
||||
mux: new TmuxManager(),
|
||||
useMux: true,
|
||||
muxSession,
|
||||
remote,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
|
||||
|
||||
const state = session.toState();
|
||||
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
|
||||
expect(state.ompConfig?.continueSession).toBe(true);
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,11 @@ import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
|
||||
import {
|
||||
findLatestOmpSessionId,
|
||||
mangleOmpWorkingDir,
|
||||
resolveAndClaimOmpSessionId,
|
||||
} from '../src/utils/omp-session-resolver.js';
|
||||
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
|
||||
|
||||
describe('mangleOmpWorkingDir', () => {
|
||||
@@ -41,6 +45,15 @@ describe('mangleOmpWorkingDir', () => {
|
||||
const sibling = `${homedir()}-other/dev/foo`;
|
||||
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash so a remote case path resolves to the same dir', () => {
|
||||
// Regression (2026-08-29): remote case paths are stored verbatim with a
|
||||
// trailing slash (e.g. `/home/user/dotfiles/`), but omp persists sessions
|
||||
// under the slash-less mangle (`-dotfiles`). Before the fix this produced
|
||||
// `-dotfiles-`, readdirSync returned null for an existing dir, and OMP
|
||||
// respawn pinning silently degraded to the ambiguous `--continue`.
|
||||
expect(mangleOmpWorkingDir(join(homedir(), 'dotfiles') + '/')).toBe('-dotfiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLatestOmpSessionId', () => {
|
||||
@@ -69,6 +82,33 @@ describe('findLatestOmpSessionId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndClaimOmpSessionId: header-cwd trailing-slash normalization', () => {
|
||||
// Sibling of the directory-mangle trailing-slash regression above, but for
|
||||
// the OTHER half of the same fix: resolveAndClaimOmpSessionId additionally
|
||||
// verifies each candidate file's own header `cwd` against workingDir (the
|
||||
// mangle is lossy, so the filename-derived id alone isn't enough — see the
|
||||
// function's doc comment). A remote case's workingDir carries a trailing
|
||||
// slash (e.g. `/home/user/dotfiles/`) but omp's header `cwd` never does;
|
||||
// without stripTrailingSlash() on BOTH sides of that comparison, a real
|
||||
// on-disk session would be found by directory but rejected by the cwd
|
||||
// check, silently degrading pinning to the ambiguous `--continue`.
|
||||
const workingDirNoSlash = join(homedir(), 'dotfiles');
|
||||
const workingDirWithSlash = `${workingDirNoSlash}/`;
|
||||
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-dotfiles');
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('matches a header cwd with no trailing slash against a workingDir that has one', () => {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
const header = `${JSON.stringify({ type: 'session', id: 'remote-dotfiles-uuid', cwd: workingDirNoSlash })}\n`;
|
||||
writeFileSync(join(sessionDir, '2026-08-29T00-00-00-000Z_remote-dotfiles-uuid.jsonl'), header);
|
||||
|
||||
expect(resolveAndClaimOmpSessionId(workingDirWithSlash)).toBe('remote-dotfiles-uuid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOmpConfigForCreate', () => {
|
||||
// The exact pipeline "resume this OMP row from the history list" drives:
|
||||
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
|
||||
|
||||
@@ -24,6 +24,31 @@ describe('COD-106 shared remote sessions', () => {
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('remote omp relaunch resumes the pinned conversation instead of starting fresh (2026-08-29)', () => {
|
||||
const cmd = buildRemoteLaunchCommand({
|
||||
mode: 'omp',
|
||||
remote,
|
||||
sessionId: 'cod106aaa',
|
||||
ompConfig: { model: 'llm-proxy/crof/glm-5.3-flash' },
|
||||
resumeSessionId: '01a04eb1-d883-75f0-bdfa-74cc315b09ce',
|
||||
});
|
||||
// The remote pane command must carry the pinned omp session id so a
|
||||
// dead-pane respawn lands back in the same conversation.
|
||||
expect(cmd).toContain('omp --model llm-proxy/crof/glm-5.3-flash --resume 01a04eb1-d883-75f0-bdfa-74cc315b09ce');
|
||||
// still a durable, idempotent remote tmux session
|
||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
||||
});
|
||||
|
||||
it('remote omp relaunch falls back to --continue when no id is pinned', () => {
|
||||
const cmd = buildRemoteLaunchCommand({
|
||||
mode: 'omp',
|
||||
remote,
|
||||
sessionId: 'cod106aaa',
|
||||
ompConfig: { continueSession: true },
|
||||
});
|
||||
expect(cmd).toContain('omp --continue');
|
||||
});
|
||||
|
||||
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
|
||||
const rows = parseRemoteSessionList(
|
||||
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
||||
|
||||
@@ -172,6 +172,43 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions');
|
||||
});
|
||||
|
||||
it('pins SSH-remote claude to the Codeman session id so a respawn resumes the same conversation', () => {
|
||||
// Regression (2026-08-29): remote claude was launched as a bare `claude …`,
|
||||
// so every reattach/respawn after a pane death (user ctrl-d or ctrl-c exit)
|
||||
// started a NEW conversation. The launch now mirrors the docker-claude shape:
|
||||
// `--session-id <id>` to create, with a `|| --resume <id>` fallback so the
|
||||
// idempotent re-run resumes instead of erroring ("already in use").
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --resume abc123def456');
|
||||
});
|
||||
|
||||
it('resumes an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand)', () => {
|
||||
// The docker-claude builder (claudeDockerPaneCommand) has always handled a
|
||||
// resumeId that differs from sessionId — e.g. a resume-from-history launch —
|
||||
// by leading with `--resume <rid> || --session-id <sessionId>`. The remote
|
||||
// claude branch used to only mirror the SAME-id fallback shape and silently
|
||||
// dropped a distinct resumeSessionId, so a remote resume-from-history claude
|
||||
// launch created a brand-new conversation instead of resuming the named one.
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
resumeSessionId: 'old-conversation-uuid',
|
||||
});
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --resume old-conversation-uuid');
|
||||
expect(command).toContain('claude --dangerously-skip-permissions --session-id abc123def456');
|
||||
// The resume attempt must lead — session-id is the fallback here, reversed
|
||||
// from the same-id case.
|
||||
const resumeIdx = command.indexOf('--resume old-conversation-uuid');
|
||||
const sessionIdIdx = command.indexOf('--session-id abc123def456');
|
||||
expect(resumeIdx).toBeLessThan(sessionIdIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
|
||||
Reference in New Issue
Block a user