Files
Codeman/test/remote-auto-reconnect.test.ts
T
Aamer Akhter 6dba8b5227 COD-108 auto-reconnect remote tmux sessions on SSH drop
Continuous remote-only reconnect watcher closing the COD-104 durability
arc: when a remote session's local ssh pane dies mid-run, re-establish it
automatically instead of leaving a dead pane until the user pokes it.

Design decisions (per cod108 design doc):
- D1 event->owner: TmuxManager watcher DETECTS a dead remote pane and emits
  `remoteSessionDropped`; the session owner (server) reassembles the same
  RespawnPaneOptions and calls Session.reattachRemote() -> respawnPane, which
  re-runs the idempotent remote command (owned new-session -A / non-owned
  attach) and REJOINS the still-running durable remote tmux session. The
  watcher never reassembles options itself, and never routes through the
  Claude-idle respawn-controller.
- D2 bounded backoff: per-session exponential backoff [5s,15s,45s,2m,5m,5m],
  reset on a successful reattach, `remoteReconnectExhausted` emitted once after
  the cap. Pure, unit-tested schedule + eligibility decision.
- D3 always-on + kill-switch: `remoteAutoReconnect` app setting (default ON),
  read each tick; when false the watcher does nothing.

Guards: killSession() (incl. the non-owned DETACH early-return) and shutdown
add the session to an intentional-teardown guard set + clear its backoff
BEFORE teardown, so a closed/killed tab is never auto-revived. Exactly one
reconnect in flight per session (inFlight guard prevents stacked respawns).
Per-session reconnect/guard state cleared on session removal.

New: src/remote-reconnect.ts (pure backoff + decideReconnect), TmuxManager
startRemoteReconnectWatcher/stop + runRemoteReconnectTick + noteRemoteReconnect
+ guardRemoteReconnect + clearRemoteReconnectState; Session.reattachRemote()
(+ extracted _buildRespawnPaneOptions, shared with interactive start); server
wiring + watcher start; 3 SSE events (sse-events.ts + constants.js in sync,
broadcast + app.js exhausted "Reconnect" affordance); remoteAutoReconnect
schema + settings-ui toggle.

Tests: test/remote-auto-reconnect.test.ts (21) - pure schedule, eligibility
(guarded never reconnects, non-remote/pane-alive/not-due skip, over-cap
exhaust), and manager-level integration (dead remote pane -> dropped ->
backoff -> exhausted; guarded emits nothing; reset-on-success; kill-switch
off; state-cleared-on-remove). Verified real-remote against aa-desktop: drop
local ssh pane -> watcher emitted -> respawnPane reattached the SAME remote
session (remote pane_pid unchanged 3939->3939); test session cleaned up, the
real host sessions left untouched.

Checks: tsc, eslint, check:frontend-syntax, check:public-assets, prettier
--check, build all green; tmux-manager/session-routes/session-manager/
sse-registry-parity suites pass.

(cherry picked from commit d13d58b1994eb6594fd2eadea208104d36204f9d)
2026-07-17 15:59:36 -04:00

299 lines
12 KiB
TypeScript

/**
* @fileoverview COD-108 — remote-session auto-reconnect watcher tests.
*
* Three layers, all tmux-safe (under VITEST TmuxManager no-ops real tmux and
* `isPaneDead` returns false, so live behavior is driven via injected stubs):
*
* (a) PURE backoff schedule — attempt→delay, cap, reset-on-success.
* (b) PURE eligibility decision — dead remote pane + due → emit; guarded →
* never; non-remote / pane-alive / not-due → skip; over-cap → exhaust.
* (c) MANAGER integration — drive ticks with a stubbed pane-death signal +
* controllable clock and assert the emit → backoff → exhausted progression,
* and that a guarded (intentionally-killed) session emits nothing.
*
* Port: N/A (no server).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
BACKOFF_SCHEDULE_MS,
MAX_RECONNECT_ATTEMPTS,
reconnectDelayForAttempt,
isExhausted,
freshReconnectState,
advanceBackoff,
resetReconnectState,
decideReconnect,
} from '../src/remote-reconnect.js';
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
const REMOTE: SessionRemote = {
hostId: 'aa-desktop',
label: 'aa-desktop',
host: 'aa-desktop',
username: 'aakhter',
remotePath: '/home/aakhter',
owned: true,
};
// ────────────────────────────────────────────────────────────────────────────
// (a) PURE backoff schedule
// ────────────────────────────────────────────────────────────────────────────
describe('reconnect backoff schedule (pure)', () => {
it('returns the documented bounded exponential delays per attempt', () => {
expect(reconnectDelayForAttempt(1)).toBe(5_000);
expect(reconnectDelayForAttempt(2)).toBe(15_000);
expect(reconnectDelayForAttempt(3)).toBe(45_000);
expect(reconnectDelayForAttempt(4)).toBe(120_000);
expect(reconnectDelayForAttempt(5)).toBe(300_000);
expect(reconnectDelayForAttempt(6)).toBe(300_000);
});
it('clamps below-range and above-range attempts to the schedule bounds', () => {
expect(reconnectDelayForAttempt(0)).toBe(BACKOFF_SCHEDULE_MS[0]);
expect(reconnectDelayForAttempt(-3)).toBe(BACKOFF_SCHEDULE_MS[0]);
expect(reconnectDelayForAttempt(99)).toBe(BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]);
});
it('flags exhaustion only at/after the cap', () => {
expect(isExhausted(0)).toBe(false);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS - 1)).toBe(false);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS)).toBe(true);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS + 1)).toBe(true);
});
it('advanceBackoff increments attempts and schedules next-eligible from now (immutable)', () => {
const s0 = freshReconnectState();
const s1 = advanceBackoff(s0, 1_000);
expect(s0.attempts).toBe(0); // input untouched
expect(s1.attempts).toBe(1);
expect(s1.nextEligibleAt).toBe(1_000 + 5_000);
expect(s1.exhausted).toBe(false);
const s2 = advanceBackoff(s1, 10_000);
expect(s2.attempts).toBe(2);
expect(s2.nextEligibleAt).toBe(10_000 + 15_000);
});
it('reaches the attempt cap after the scheduled number of advances', () => {
let s = freshReconnectState();
let now = 0;
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
s = advanceBackoff(s, now);
now += reconnectDelayForAttempt(s.attempts);
}
expect(s.attempts).toBe(MAX_RECONNECT_ATTEMPTS);
// advanceBackoff does not itself set `exhausted`; the watcher decides that
// on the following tick via isExhausted(attempts).
expect(isExhausted(s.attempts)).toBe(true);
});
it('reset-on-success returns to a fresh, immediately-eligible state', () => {
const advanced = advanceBackoff(advanceBackoff(freshReconnectState(), 0), 100);
expect(advanced.attempts).toBe(2);
const reset = resetReconnectState();
expect(reset.attempts).toBe(0);
expect(reset.nextEligibleAt).toBe(0);
expect(reset.exhausted).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────────────
// (b) PURE eligibility decision
// ────────────────────────────────────────────────────────────────────────────
describe('decideReconnect (pure eligibility)', () => {
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
it('emits for a dead remote pane that is not guarded and is due', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'emit', attempt: 1 });
});
it('NEVER reconnects a guarded (intentionally killed/detached) session', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: true,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'guarded' });
});
it('skips non-remote sessions', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: false, paneDead: true },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'not-remote' });
});
it('skips when the pane is alive', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: true, paneDead: false },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
});
it('skips when the kill-switch is off', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: false,
enabled: false,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'disabled' });
});
it('skips when not yet due (within backoff window)', () => {
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 4_999 });
expect(action).toEqual({ kind: 'skip', reason: 'not-due' });
});
it('emits again once the backoff window elapses', () => {
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 5_000 });
expect(action).toEqual({ kind: 'emit', attempt: 2 });
});
it('skips while a reconnect is already in flight (no stacked respawns)', () => {
const state = { ...freshReconnectState(), inFlight: true };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 10_000 });
expect(action).toEqual({ kind: 'skip', reason: 'in-flight' });
});
it('exhausts once the attempt cap is reached', () => {
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, nextEligibleAt: 0 };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
expect(action).toEqual({ kind: 'exhaust' });
});
it('stays quiet after exhaustion has been recorded', () => {
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, exhausted: true };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
expect(action).toEqual({ kind: 'skip', reason: 'exhausted' });
});
});
// ────────────────────────────────────────────────────────────────────────────
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
// ────────────────────────────────────────────────────────────────────────────
describe('TmuxManager remote reconnect watcher (integration)', () => {
let manager: TmuxManager;
beforeEach(() => {
manager = new TmuxManager();
});
function registerRemote(sessionId: string): void {
manager.registerSession({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 4242,
createdAt: Date.now(),
workingDir: '/home/aakhter',
mode: 'shell',
attached: true,
remote: REMOTE,
});
}
it('emits remoteSessionDropped when a dead remote pane is observed, then backs off and exhausts', () => {
registerRemote('aaaa1111');
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: Array<{ sessionId: string; attempt: number }> = [];
const exhausted: Array<{ sessionId: string }> = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.on('remoteReconnectExhausted', (d) => exhausted.push(d));
// Drive ticks with a controllable clock. Each emit marks the session
// in-flight; a failed reattach (host still down) releases it via
// noteRemoteReconnect(false), mirroring the real server loop.
let now = 0;
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS + 3; i++) {
manager.runRemoteReconnectTick(now, /* enabled */ true);
manager.noteRemoteReconnect('aaaa1111', false); // reattach failed → clear in-flight
// jump the clock past the just-scheduled backoff window
now += BACKOFF_SCHEDULE_MS[Math.min(i, BACKOFF_SCHEDULE_MS.length - 1)] + 1;
}
expect(dropped.map((d) => d.attempt)).toEqual([1, 2, 3, 4, 5, 6]);
expect(dropped.every((d) => d.sessionId === 'aaaa1111')).toBe(true);
expect(exhausted).toEqual([{ sessionId: 'aaaa1111' }]); // fired exactly once
});
it('emits nothing for a guarded (intentionally killed) session', () => {
registerRemote('bbbb2222');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
manager.guardRemoteReconnect('bbbb2222'); // simulate killSession/detach guard
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
let now = 0;
for (let i = 0; i < 5; i++) {
manager.runRemoteReconnectTick(now, true);
now += 600_000;
}
expect(dropped).toEqual([]);
});
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
registerRemote('cccc3333');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: Array<{ attempt: number }> = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, true); // emit attempt 1
manager.noteRemoteReconnect('cccc3333', true); // reattach succeeded → reset
manager.runRemoteReconnectTick(1, true); // immediately eligible again → attempt 1
expect(dropped.map((d) => d.attempt)).toEqual([1, 1]);
});
it('does nothing when the kill-switch (enabled=false) is off', () => {
registerRemote('dddd4444');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, false);
expect(dropped).toEqual([]);
});
it('clears per-session reconnect/guard state when the session is removed', () => {
registerRemote('eeee5555');
manager.guardRemoteReconnect('eeee5555');
manager.clearRemoteReconnectState('eeee5555');
// After clearing the guard, a fresh dead-pane observation should emit again.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, true);
expect(dropped).toEqual([{ sessionId: 'eeee5555', attempt: 1 }]);
});
});