mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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 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:
@@ -106,7 +106,7 @@ describe('reconnect backoff schedule (pure)', () => {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decideReconnect (pure eligibility)', () => {
|
||||
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
|
||||
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: true };
|
||||
|
||||
it('emits for a dead remote pane that is not guarded and is due', () => {
|
||||
const action = decideReconnect({
|
||||
@@ -132,7 +132,7 @@ describe('decideReconnect (pure eligibility)', () => {
|
||||
|
||||
it('skips non-remote sessions', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: false, paneDead: true },
|
||||
session: { sessionId: 's1', isRemote: false, paneDead: true, remoteAlive: true },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
@@ -143,7 +143,7 @@ describe('decideReconnect (pure eligibility)', () => {
|
||||
|
||||
it('skips when the pane is alive', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: false },
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: false, remoteAlive: true },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
@@ -152,6 +152,28 @@ describe('decideReconnect (pure eligibility)', () => {
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
|
||||
});
|
||||
|
||||
it('NEVER revives when the durable remote tmux is GONE (clean exit — the 2026-08-29 fix)', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: false },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' });
|
||||
});
|
||||
|
||||
it('NEVER revives when remote liveness is unknown (probe failed — fail closed)', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: true, remoteAlive: undefined },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'remote-gone' });
|
||||
});
|
||||
|
||||
it('skips when the kill-switch is off', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
@@ -222,6 +244,11 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
registerRemote('aaaa1111');
|
||||
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
// The durable remote tmux is still alive (transport drop) → reconnect allowed.
|
||||
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
|
||||
'aaaa1111',
|
||||
true
|
||||
);
|
||||
|
||||
const dropped: Array<{ sessionId: string; attempt: number }> = [];
|
||||
const exhausted: Array<{ sessionId: string }> = [];
|
||||
@@ -263,6 +290,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
|
||||
registerRemote('cccc3333');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
|
||||
'cccc3333',
|
||||
true
|
||||
);
|
||||
|
||||
const dropped: Array<{ attempt: number }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
@@ -290,6 +321,10 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
manager.clearRemoteReconnectState('eeee5555');
|
||||
// After clearing the guard, a fresh dead-pane observation should emit again.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
(manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache.set(
|
||||
'eeee5555',
|
||||
true
|
||||
);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
|
||||
Reference in New Issue
Block a user