mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
feat(remote): wake a sleeping host when a session is created or attached
Pressing Run on a remote case whose host was asleep failed with `could not verify tmux on remote host 192.168.50.137: …` — an ssh error that blames tmux for a machine that is merely suspended. The only wake paths were typed input on an established session and the banner's Wake button, so OPENING a session (the moment the user actually decides to use that host) had none. `RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness machinery for a host that has no session yet, and is wired into the two user-initiated create paths: `POST /api/quick-start` for a remote case (before the tmux prereq probe, which is what surfaced the misleading error) and `POST /api/sessions` with `attachRemoteSession`. A host without a wake target is not even probed, so its behavior and latency are byte-identical. The wake is blocking — the caller gets the session or an error — but bounded by REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default, because the dashboard sits behind a reverse proxy whose default `proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while the session was still being created. The budget has to cover the whole request (40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which is why it is 40 s and not 45. A timeout now says the host did not come back, and an unreachable host without a wake target says so instead of pointing at tmux. The wiring is deliberately in the HTTP ROUTE, never in the shared session service: `cron-service.ts` builds sessions there with nobody waiting on the answer, and a wake on that path would power the host on for every schedule — the timer-driven re-wake invariant #1 exists to prevent. Both halves are asserted (importers of `remote-wake`, and `ensureHostAwake` having exactly one caller file), so a future caller has to come through the guard test. A rejection from the wake IO is caught too: a broken target must fail the wake, not the route. `remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the session-less case, where "input is queued" would be untrue; the toast then reads "the session starts when it is back". Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the route behavior is covered by new tests in session-routes.test.ts with an injected registry, so no test opens a real socket or ssh.
This commit is contained in:
+156
-29
@@ -8,11 +8,16 @@
|
||||
* lost with no error anywhere — the failure this module exists to close.
|
||||
*
|
||||
* Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
|
||||
* - ONLY real user input wakes a host. The auto-reconnect watcher and
|
||||
* boot-recovery must never wake one, or a host would be re-woken ~45 s after
|
||||
* each suspend and could never stay asleep (the "keepalive pings a sleeping
|
||||
* host" failure already solved for a different consumer by
|
||||
* `hufflepuff-mcp-lazy`).
|
||||
* - An EXPLICIT request wakes a host, and nothing else: user input on an
|
||||
* established session (`handleInput`), the wake button (`ensureAwake`), or the
|
||||
* user's own session create/attach request (`ensureHostAwake`, wired in the HTTP
|
||||
* routes). Everything that runs on a TIMER — the auto-reconnect watcher, boot
|
||||
* recovery, the reachability probe, session discovery — must never wake one, or
|
||||
* a host would be re-woken ~45 s after each suspend and could never stay asleep
|
||||
* (the "keepalive pings a sleeping host" failure already solved for a different
|
||||
* consumer by `hufflepuff-mcp-lazy`). The create path is deliberately wired in
|
||||
* `session-routes.ts` and NOT in the shared session service, because
|
||||
* `cron-service.ts` builds sessions there without a user waiting on the answer.
|
||||
* - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client,
|
||||
* a few hundred bytes — below any meaningful activity threshold), throttled
|
||||
* per session. No SSH keepalive is added to the launch command: keepalives
|
||||
@@ -41,6 +46,17 @@ export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
|
||||
export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
|
||||
/** Bounded wait for the host to come back after the wake command ran. */
|
||||
export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
|
||||
/**
|
||||
* Budget for a wake that an HTTP REQUEST is waiting on (session create/attach).
|
||||
* Deliberately shorter than {@link REMOTE_WAKE_READY_TIMEOUT_MS}: the dashboard is
|
||||
* served through a reverse proxy whose default `proxy_read_timeout` is 60 s, so a
|
||||
* 90 s wait would be cut off AT THE PROXY while the session was still being built —
|
||||
* the browser reports a failure for a session that exists. The budget has to cover
|
||||
* the WHOLE request, not just the wait: 40 s here + the 1.5 s reachability probe +
|
||||
* the tmux prereq probe's own 15 s timeout = 56.5 s worst case, still under 60 s.
|
||||
* A warm S3 resume measures ~12 s, so 40 s is >3× the observed wake.
|
||||
*/
|
||||
export const REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS = 40_000;
|
||||
/** The wake command itself must not hang the wake flow. */
|
||||
export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
@@ -224,7 +240,7 @@ export interface RemoteWakeDeps {
|
||||
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
|
||||
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
|
||||
/** Poll until the woken host accepts connections again. */
|
||||
waitUntilReady(remote: WakeableRemote): Promise<boolean>;
|
||||
waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number }): Promise<boolean>;
|
||||
/** Sleep helper (injected for tests). */
|
||||
delay(ms: number): Promise<void>;
|
||||
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
|
||||
@@ -264,6 +280,25 @@ export function wakeConfigured(remote: WakeableRemote | undefined): WakeConfigur
|
||||
return target.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of waking a host for a caller that has NO session yet (the create/attach
|
||||
* routes). A union rather than a boolean because the three cases need different
|
||||
* handling: `'no-target'` must leave the caller's behavior byte-identical (no probe,
|
||||
* no extra latency for a host without WoL), and only `'failed'` is an error that
|
||||
* deserves its own message instead of the caller's usual one.
|
||||
*/
|
||||
export type HostWakeOutcome = 'no-target' | 'ready' | 'failed';
|
||||
|
||||
/**
|
||||
* State key for a host-scoped wake. Prefixed so it can never collide with a session
|
||||
* id, and keyed on the HOST rather than the case: two cases on one host share a
|
||||
* single in-flight wake and one probe verdict. Such an entry is tiny (no input
|
||||
* buffer) and bounded by the number of configured hosts, so it is never dropped.
|
||||
*/
|
||||
function hostWakeKey(hostId: string): string {
|
||||
return `host:${hostId}`;
|
||||
}
|
||||
|
||||
/** Per-session wake bookkeeping. */
|
||||
interface WakeState {
|
||||
probedAt: number;
|
||||
@@ -391,6 +426,67 @@ export class RemoteWakeRegistry {
|
||||
return this.wake(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-scoped reachability, for a caller that has no session yet (create/attach).
|
||||
* Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the
|
||||
* wake flow just paid for also answers "was that ssh failure really a sleeping
|
||||
* machine?". Never wakes anything — it is a question, not an action.
|
||||
*/
|
||||
async checkHostReachable(remote: WakeableRemote, opts: { force?: boolean; ttlMs?: number } = {}): Promise<boolean> {
|
||||
const state = this._state(hostWakeKey(remote.hostId));
|
||||
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
|
||||
if (Date.now() - state.probedAt >= ttl) {
|
||||
state.probedAt = Date.now();
|
||||
state.reachable = await this.deps.probe(remote);
|
||||
}
|
||||
return state.reachable === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake a host for a REQUEST that is waiting on it — the session create/attach
|
||||
* routes, where there is no session to reattach and no input to buffer yet.
|
||||
*
|
||||
* `'no-target'` returns without probing, so a host without WoL config costs
|
||||
* nothing and behaves exactly as before. Single-flight per host, so a double click
|
||||
* (or two cases on the same host) sends one packet and shares one readiness poll.
|
||||
*/
|
||||
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
|
||||
if (!resolveWakeTarget(remote)) return 'no-target';
|
||||
const state = this._state(hostWakeKey(remote.hostId));
|
||||
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
|
||||
|
||||
state.probedAt = Date.now();
|
||||
state.reachable = await this.deps.probe(remote);
|
||||
if (state.reachable) return 'ready';
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} (${remote.host}) is unreachable — waking it for a new session`);
|
||||
return (await this.wakeHost(remote, opts)) ? 'ready' : 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-flight wake for a host with no session (see {@link ensureHostAwake}).
|
||||
* A write into the same `waking` slot the session flow uses, so the two can never
|
||||
* run two readiness polls against one host from the same key space.
|
||||
*/
|
||||
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
|
||||
const state = this._state(hostWakeKey(remote.hostId));
|
||||
if (state.waking) return state.waking;
|
||||
state.waking = (async (): Promise<boolean> => {
|
||||
try {
|
||||
return await this._wakeAndWait(remote, state, { timeoutMs: opts.timeoutMs, forNewSession: true });
|
||||
} catch (err) {
|
||||
// Injected IO is documented not to throw, but a rejected promise here would
|
||||
// surface as an unhandled rejection AND take the route down with it (the
|
||||
// session path catches for exactly this reason). A broken wake target must
|
||||
// fail the wake, never the create route beyond its own error response.
|
||||
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
} finally {
|
||||
state.waking = null;
|
||||
}
|
||||
})();
|
||||
return state.waking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-flight wake: probe-free (the caller already knows the host is down),
|
||||
* run the wake command, poll for readiness, reattach the pane, flush the buffer.
|
||||
@@ -405,29 +501,9 @@ export class RemoteWakeRegistry {
|
||||
state.waking = (async (): Promise<boolean> => {
|
||||
const id = session.id;
|
||||
try {
|
||||
this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label });
|
||||
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} for session ${id}`);
|
||||
const ready = await this._wakeAndWait(remote, state, { sessionId: id });
|
||||
if (!ready) return false;
|
||||
|
||||
const woke = await this.deps.wake(target);
|
||||
if (!woke) {
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`
|
||||
);
|
||||
}
|
||||
|
||||
const ready = await this.deps.waitUntilReady(remote);
|
||||
if (!ready) {
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — input stays buffered`);
|
||||
this.deps.broadcast?.('remote:hostWakeFailed', { sessionId: id, hostId: remote.hostId, label: remote.label });
|
||||
// Reset the probe state so the NEXT user input probes and retries
|
||||
// instead of trusting a stale "down" verdict forever.
|
||||
state.probedAt = 0;
|
||||
state.reachable = undefined;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.reachable = true;
|
||||
state.probedAt = Date.now();
|
||||
const reattached = await session.reattachRemote();
|
||||
if (!reattached) {
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
|
||||
@@ -453,6 +529,57 @@ export class RemoteWakeRegistry {
|
||||
return state.waking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast + run the wake target + wait for SSH. Shared by the session flow (which
|
||||
* then reattaches and flushes the buffer) and the create/attach flow (which has no
|
||||
* pane yet). On failure the probe state is reset so the NEXT attempt probes and
|
||||
* retries instead of trusting a stale "down" verdict forever.
|
||||
*/
|
||||
private async _wakeAndWait(
|
||||
remote: WakeableRemote,
|
||||
state: WakeState,
|
||||
opts: { sessionId?: string; timeoutMs?: number; forNewSession?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const target = resolveWakeTarget(remote);
|
||||
if (!target) return true;
|
||||
const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session';
|
||||
// No `sessionId` for a create-path wake: the toast handler is then the only one
|
||||
// that acts (a banner for a session that does not exist yet would have no target),
|
||||
// which is exactly the `forNewSession` distinction the UI renders.
|
||||
this.deps.broadcast?.('remote:hostWaking', {
|
||||
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||
hostId: remote.hostId,
|
||||
label: remote.label,
|
||||
});
|
||||
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
|
||||
|
||||
const woke = await this.deps.wake(target);
|
||||
if (!woke) {
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`
|
||||
);
|
||||
}
|
||||
|
||||
const ready = await this.deps.waitUntilReady(remote, { timeoutMs: opts.timeoutMs });
|
||||
if (!ready) {
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
|
||||
);
|
||||
this.deps.broadcast?.('remote:hostWakeFailed', {
|
||||
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||
hostId: remote.hostId,
|
||||
label: remote.label,
|
||||
});
|
||||
state.probedAt = 0;
|
||||
state.reachable = undefined;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.reachable = true;
|
||||
state.probedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _state(sessionId: string): WakeState {
|
||||
let state = this.states.get(sessionId);
|
||||
if (!state) {
|
||||
@@ -675,7 +802,7 @@ export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> =
|
||||
return {
|
||||
probe: probeRemoteHostReachable,
|
||||
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
|
||||
waitUntilReady: (remote) => waitUntilRemoteReady(remote),
|
||||
waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, opts),
|
||||
delay,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -326,9 +326,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_onRemoteHostWaking(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
// A create-path wake (the user pressed Run / Attach) has no session yet, so
|
||||
// nothing is queued behind it — the wording has to say what actually happens.
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
|
||||
// is replaced by `remote:sessionReconnected` the moment the pane is back.
|
||||
this.showToast(`Waking ${label} … input is queued`, 'info', { duration: 12000 });
|
||||
this.showToast(
|
||||
forNewSession ? `Waking ${label} … the session starts when it is back` : `Waking ${label} … input is queued`,
|
||||
'info',
|
||||
{ duration: 12000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = true;
|
||||
@@ -340,7 +347,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */
|
||||
_onRemoteHostWakeFailed(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
this.showToast(`${label} did not wake up — queued input is still held`, 'error', { duration: 15000 });
|
||||
const forNewSession = Boolean(data && data.forNewSession);
|
||||
this.showToast(
|
||||
forNewSession
|
||||
? `${label} did not wake up — no session was started`
|
||||
: `${label} did not wake up — queued input is still held`,
|
||||
'error',
|
||||
{ duration: 15000 }
|
||||
);
|
||||
const state = this._hostWake;
|
||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||
state.waking = false;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -67,7 +68,12 @@ import {
|
||||
type WaitSignal,
|
||||
type SignalWaitResult,
|
||||
} from '../session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js';
|
||||
import {
|
||||
RemoteWakeRegistry,
|
||||
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
createDefaultRemoteWakeDeps,
|
||||
type WakeableRemote,
|
||||
} from '../../remote-wake.js';
|
||||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -816,6 +822,22 @@ export function resolveOmpConfigForCreate(
|
||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* `RemoteHost` → the wake registry's host shape. They differ in one field name only
|
||||
* (`id` in host config vs `hostId` on a session's `remote`), but the rename is load-
|
||||
* bearing: the registry keys its per-host wake state on `hostId`.
|
||||
*/
|
||||
function wakeableHost(host: RemoteHost): WakeableRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
port: host.port,
|
||||
wakeMac: host.wakeMac,
|
||||
wakeCommand: host.wakeCommand,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||
@@ -928,6 +950,19 @@ export function registerSessionRoutes(
|
||||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
// An explicit wake request is the only thing that may wake a host, and the user
|
||||
// pressing Attach IS one (see quick-start for the same gate, and
|
||||
// `remote-wake.ts` for what must never call this). Without it a sleeping host
|
||||
// answers with an ssh failure that blames anything but the machine being asleep.
|
||||
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
});
|
||||
if (hostWake === 'failed') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`${host.label} did not come back after a wake-on-LAN request — nothing was attached`
|
||||
);
|
||||
}
|
||||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
@@ -3272,11 +3307,39 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// The user pressing "Run" on a case whose host is asleep IS an explicit wake
|
||||
// request (docs/remote-sessions.md §Wake-on-LAN), and the tmux probe below would
|
||||
// otherwise fail with "could not verify tmux on remote host …" — an ssh failure
|
||||
// that blames tmux for a machine that is merely suspended. Wired HERE, in the HTTP
|
||||
// route, and deliberately NOT in the shared session service: `cron-service.ts`
|
||||
// builds sessions through the service, and a wake down there would re-wake the
|
||||
// host on every schedule (the failure invariant #1 exists to prevent).
|
||||
const hostWake = await remoteWake.ensureHostAwake(wakeableHost(host), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
});
|
||||
if (hostWake === 'failed') {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
`${host.label} did not come back after a wake-on-LAN request — the session was not started`
|
||||
);
|
||||
}
|
||||
|
||||
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
||||
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
||||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
// An unreachable host and a host without tmux fail the same way over ssh, so the
|
||||
// probe's own message would send the user hunting for a tmux install. Ask the
|
||||
// registry (which just probed, when it woke the host) which of the two it is.
|
||||
if (!(await remoteWake.checkHostReachable(wakeableHost(host)))) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
hostWake === 'no-target'
|
||||
? `${host.label} (${host.host}) is not reachable, and this host has no wake-on-LAN target — configure a MAC address or a wake command first`
|
||||
: `${host.label} (${host.host}) is not reachable`
|
||||
);
|
||||
}
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user