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 claude/opencode/omp 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.

Tests: 3 new cases pinning remote-gone / unknown / alive decisions.
Verified live: all remote CLIs stay dead after ctrl-c/ctrl-d.
This commit is contained in:
timkjr
2026-08-29 17:43:07 -05:00
parent 23fae0c5af
commit da5f5447d0
4 changed files with 155 additions and 6 deletions
+45 -2
View File
@@ -64,6 +64,7 @@ import {
defaultRemoteCommandForMode,
remoteLoginShellCommand,
remoteSshTarget,
remoteTmuxSessionAlive,
} from './remote-hosts.js';
import {
buildDockerBaseArgs,
@@ -1674,6 +1675,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
*/
private reconnectGuard: Set<string> = new Set();
/**
* Cached result of the remote tmux `has-session` probe (sessionId → alive).
* `true` = the durable remote tmux session exists (transport drop → reconnect
* is safe); `false` = remote session gone (agent exited cleanly → do NOT
* reconnect); `undefined` = not yet probed / probe failed. Only sessions
* whose pane is otherwise dead+eligible get probed, so a clean exit tears
* down the remote tmux and the probe reports false — killing the auto-revive
* (found live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned
* fresh agents because the watcher couldn't tell a clean exit from a
* transport drop).
*/
private remoteAliveCache: Map<string, boolean | undefined> = new Map();
private trueColorConfigured = false;
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
@@ -2134,7 +2147,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
// 1. Create session with default shell (starts tmux server, stays alive)
// 2. Set remain-on-exit (server now exists, session won't vanish on exit)
// 3. Replace shell with actual command via respawn-pane (no terminal echo)
// Unset $TMUX so nested sessions work when the dev server itself runs inside tmux.
@@ -3111,16 +3123,44 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* applies the pure {@link decideReconnect} decision and translates the result
* into events + backoff/state transitions. Public for tests + the watcher.
*/
/**
* Refresh the cached remote-tmux liveness for a session whose pane is dead.
* Fire-and-forget (async, not awaited by the sync tick): the probe is a slow
* ssh round-trip, so it must not block the 5s watcher interval. On success it
* writes the cached result; the NEXT tick then makes the revive decision with
* fresh data. A clean exit makes the remote tmux session vanish, so the probe
* resolves false and the watcher stops reviving it (2026-08-29).
*/
private async refreshRemoteAlive(session: MuxSession): Promise<void> {
if (!session.remote) return;
const remoteName = session.remote.remoteSessionName || remoteTmuxSessionName(session.sessionId);
try {
const alive = await remoteTmuxSessionAlive(session.remote, remoteName);
this.remoteAliveCache.set(session.sessionId, alive);
} catch {
this.remoteAliveCache.set(session.sessionId, undefined);
}
}
runRemoteReconnectTick(now: number, enabled: boolean): void {
for (const session of this.sessions.values()) {
if (!session.remote) continue;
const sessionId = session.sessionId;
const state = this.reconnectState.get(sessionId);
// Only probe when the pane is actually dead — otherwise the ssh round-trip
// would run every 5s for every healthy remote session. The cache is
// refreshed lazily so a clean exit (remote tmux gone) flips it to false
// on the next tick and stops the auto-revive.
const paneDead = this.isPaneDead(session.muxName);
if (paneDead && this.remoteAliveCache.get(sessionId) === undefined) {
void this.refreshRemoteAlive(session);
}
const action = decideReconnect({
session: {
sessionId,
isRemote: true,
paneDead: this.isPaneDead(session.muxName),
paneDead,
remoteAlive: this.remoteAliveCache.get(sessionId),
},
state,
guarded: this.reconnectGuard.has(sessionId),
@@ -3168,12 +3208,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
guardRemoteReconnect(sessionId: string): void {
this.reconnectGuard.add(sessionId);
this.reconnectState.delete(sessionId);
this.remoteAliveCache.delete(sessionId);
}
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
clearRemoteReconnectState(sessionId: string): void {
this.reconnectState.delete(sessionId);
this.reconnectGuard.delete(sessionId);
this.remoteAliveCache.delete(sessionId);
}
destroy(): void {
@@ -3182,6 +3224,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
this.reconnectGuard.clear();
this.remoteAliveCache.clear();
}
registerSession(session: MuxSession): void {