mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(remote): never auto-revive a remote session after a clean agent exit
The COD-108 reconnect watcher treated any dead local pane as a dropped transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a remote omp/opencode/claude auto-spawned a FRESH agent (claude only looked correct because its '--session-id || --resume' fallback resumed, with a loud 'already in use' error first). Distinguish a transport drop from an intentional exit: only reconnect when the durable remote tmux session (codeman-ssh-*) is verifiably still alive on the remote host. A clean exit tears that session down; the watcher now probes it via ssh has-session and skips (remote-gone) when it is gone OR unknown (fail closed). The probe is cached per-session and fired async so the 5s tick never blocks on ssh. Also thread ompConfig/resumeSessionId into the remote builders so a dead-pane respawn of an omp session resumes (--resume <id>) or continues (--continue) instead of launching bare omp. Tests: 3 new cases pinning remote-gone / unknown / alive decisions; remote omp resume + --continue fallback. Verified live: all three remote CLIs stay dead after exit.
This commit is contained in:
+33
-3
@@ -760,8 +760,11 @@ export function buildRemoteLaunchCommand(options: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
|
/** OMP only — resume/continue overrides for the remote omp relaunch (dead-pane respawn). */
|
||||||
|
ompConfig?: OmpConfig;
|
||||||
|
resumeSessionId?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
|
const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
|
||||||
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
|
||||||
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
||||||
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
||||||
@@ -788,6 +791,30 @@ export function buildRemoteLaunchCommand(options: {
|
|||||||
modeCommand = remoteLoginShellCommand(
|
modeCommand = remoteLoginShellCommand(
|
||||||
`claude${permFlags} --session-id ${sessionId} || claude${permFlags} --resume ${sessionId}`
|
`claude${permFlags} --session-id ${sessionId} || claude${permFlags} --resume ${sessionId}`
|
||||||
);
|
);
|
||||||
|
} else if (mode === 'omp') {
|
||||||
|
// Remote OMP respawn must RESUME the same conversation instead of
|
||||||
|
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
|
||||||
|
// a brand-new omp session). The pinned id, when known, is passed as an
|
||||||
|
// explicit --resume; otherwise fall back to omp's own "most recent"
|
||||||
|
// --continue so a dead-pane respawn still lands back in the conversation.
|
||||||
|
// Rendered through the CLI registry (buildSpawnCommandFromRegistry), the
|
||||||
|
// SAME mode-agnostic path local/docker spawns use — not appendResumeFlag(),
|
||||||
|
// which would hand the id to the login shell as $0 after the quoted `-c
|
||||||
|
// 'omp'`, and not a raw buildOmpCommand() call, which the registry refactor
|
||||||
|
// (#347) deleted. Gives every registry CLI with a resume form this
|
||||||
|
// behaviour for free, and the flags can't drift from the local builder.
|
||||||
|
const ompEntry = getCli('omp');
|
||||||
|
const ompCmd = ompEntry
|
||||||
|
? (buildSpawnCommandFromRegistry(ompEntry, {
|
||||||
|
mode: 'omp',
|
||||||
|
sessionId,
|
||||||
|
ompConfig: {
|
||||||
|
...ompConfig,
|
||||||
|
resumeSessionId: resumeSessionId || ompConfig?.resumeSessionId,
|
||||||
|
},
|
||||||
|
}) ?? 'omp')
|
||||||
|
: 'omp';
|
||||||
|
modeCommand = remoteLoginShellCommand(ompCmd);
|
||||||
} else {
|
} else {
|
||||||
modeCommand = defaultRemoteCommandForMode(mode);
|
modeCommand = defaultRemoteCommandForMode(mode);
|
||||||
}
|
}
|
||||||
@@ -1258,6 +1285,9 @@ function buildRemoteSessionCommand(options: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
claudeMode?: ClaudeMode;
|
claudeMode?: ClaudeMode;
|
||||||
allowedTools?: string;
|
allowedTools?: string;
|
||||||
|
/** OMP only — resume/continue overrides for a remote omp relaunch. */
|
||||||
|
ompConfig?: OmpConfig;
|
||||||
|
resumeSessionId?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { remote, sessionId } = options;
|
const { remote, sessionId } = options;
|
||||||
if (remote.owned === false) {
|
if (remote.owned === false) {
|
||||||
@@ -1831,7 +1861,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const fullCmd = docker
|
const fullCmd = docker
|
||||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||||
: remote
|
: remote
|
||||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||||
: localFullCmd;
|
: localFullCmd;
|
||||||
|
|
||||||
// Create tmux session in three steps to handle cold-start (no server running)
|
// Create tmux session in three steps to handle cold-start (no server running)
|
||||||
@@ -2082,7 +2112,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const fullCmd = docker
|
const fullCmd = docker
|
||||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||||
: remote
|
: remote
|
||||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
||||||
: localFullCmd;
|
: localFullCmd;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
|||||||
"tmux-manager.ts::mode === 'claude'":
|
"tmux-manager.ts::mode === 'claude'":
|
||||||
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
"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',
|
'`--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 ---
|
// --- Per-CLI prose and launch handling not yet generalised ---
|
||||||
"web/session-wait-registry.ts::mode === 'deepseek'":
|
"web/session-wait-registry.ts::mode === 'deepseek'":
|
||||||
|
|||||||
@@ -24,6 +24,31 @@ describe('COD-106 shared remote sessions', () => {
|
|||||||
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
|
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)', () => {
|
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
|
||||||
const rows = parseRemoteSessionList(
|
const rows = parseRemoteSessionList(
|
||||||
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
|
||||||
|
|||||||
Reference in New Issue
Block a user