mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(remote): wake a sleeping host from user input (Wake-on-LAN)
A durable remote session survives SSH drops (COD-104/108), but nothing brought the HOST back: after the remote machine suspended, the local tmux pane's ssh child stalled silently and `send-keys` SUCCEEDS against it, so typed input vanished with no error anywhere. Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that the input route runs when a wake-enabled host is unreachable: input is buffered, the host is woken, the pane is reattached, and the buffer is flushed in order. Detection is a throttled bare TCP probe on wake-enabled hosts only, and only REAL user input may wake a host - the auto-reconnect watcher and boot recovery deliberately cannot, or the host would be re-woken seconds after every suspend and could never stay asleep.
This commit is contained in:
@@ -219,6 +219,8 @@ const _SSE_HANDLER_MAP = [
|
||||
// Remote auto-reconnect (COD-108)
|
||||
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
|
||||
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
|
||||
[SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'],
|
||||
[SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'],
|
||||
|
||||
// Ralph
|
||||
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
|
||||
|
||||
@@ -1057,6 +1057,9 @@ const SSE_EVENTS = {
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
// Wake-on-LAN from user input on a sleeping remote host
|
||||
REMOTE_HOST_WAKING: 'remote:hostWaking',
|
||||
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
|
||||
@@ -116,6 +116,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
|
||||
_onRemoteHostWaking(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
// 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 });
|
||||
},
|
||||
|
||||
_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 });
|
||||
},
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
this.handleBashToolStart(data.sessionId, data.tool);
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
type WaitSignal,
|
||||
type SignalWaitResult,
|
||||
} from '../session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js';
|
||||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -816,8 +817,32 @@ export function resolveOmpConfigForCreate(
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||
): void {
|
||||
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
|
||||
// route registration (= one web server) — the same shape as the process-wide
|
||||
// `sessionWaits` singleton, but without the global.
|
||||
//
|
||||
// ⚠️ The ONLY caller that may wake a host is the input route below. The
|
||||
// auto-reconnect watcher and boot recovery deliberately have no access to this
|
||||
// registry: waking there would re-wake the host seconds after every suspend, so
|
||||
// it could never stay asleep.
|
||||
const remoteWake =
|
||||
options.remoteWake ??
|
||||
new RemoteWakeRegistry(
|
||||
createDefaultRemoteWakeDeps({
|
||||
noteReconnected: (sessionId, success) => {
|
||||
// Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff
|
||||
// state, and the port interface does not expose it.
|
||||
const mux = ctx.mux as unknown as { noteRemoteReconnect?: (id: string, ok: boolean) => void };
|
||||
mux.noteRemoteReconnect?.(sessionId, success);
|
||||
},
|
||||
broadcast: (event, payload) => ctx.broadcast(event, payload),
|
||||
log: (message) => console.log(message),
|
||||
})
|
||||
);
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1279,6 +1304,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
remoteWake.drop(session.id);
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1555,6 +1581,28 @@ export function registerSessionRoutes(
|
||||
return {};
|
||||
}
|
||||
|
||||
// Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves
|
||||
// the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes
|
||||
// would vanish with no error anywhere. Give the registry the chance to probe the
|
||||
// host, wake it, reattach, and own delivery before we write into nothing.
|
||||
//
|
||||
// Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is
|
||||
// known reachable inside the probe throttle window; the probe itself is a bare
|
||||
// TCP connect on wake-enabled hosts only, at most once per
|
||||
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
|
||||
if (!duplicate && session.remote?.wakeCommand) {
|
||||
if (wantsWait) {
|
||||
// Send-and-wait keeps the response open anyway, so blocking on the wake is
|
||||
// simpler and more correct than buffering (buffering would break the wait).
|
||||
await remoteWake.ensureAwake(session);
|
||||
} else if ((await remoteWake.handleInput(session, inputStr)) === 'buffered') {
|
||||
// The registry holds the bytes and flushes them in order once the pane is
|
||||
// reattached. The client's ACK is this 200 — a tagged retry is deduped
|
||||
// (`shouldApplyInput` above already consumed the seq), so nothing is lost.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
||||
// (thousands of calls per session) must stay exec-free.
|
||||
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
||||
|
||||
@@ -737,6 +737,18 @@ export const RemoteHostSchema = z.object({
|
||||
.max(32)
|
||||
.optional(),
|
||||
commands: RemoteCommandOverridesSchema,
|
||||
// Wake-on-LAN: a single executable path (no arguments, no shell) run to power a
|
||||
// SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without
|
||||
// a shell, so there is no shell layer to escape; the regexes are belt-and-braces
|
||||
// (and the no-whitespace rule rejects an argument list before it can fail as a
|
||||
// confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN.
|
||||
wakeCommand: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in wake command')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
|
||||
@@ -184,6 +184,13 @@ export const RemoteSessionDropped = 'remote:sessionDropped' as const;
|
||||
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
|
||||
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
|
||||
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
|
||||
/**
|
||||
* User input arrived for a session whose host is unreachable, so a Wake-on-LAN
|
||||
* command was started (see `remote-wake.ts`). Input sent meanwhile is buffered.
|
||||
*/
|
||||
export const RemoteHostWaking = 'remote:hostWaking' as const;
|
||||
/** The host did not come back within the wake timeout — buffered input is still held. */
|
||||
export const RemoteHostWakeFailed = 'remote:hostWakeFailed' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -535,6 +542,8 @@ export const SseEvent = {
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
RemoteReconnectExhausted,
|
||||
RemoteHostWaking,
|
||||
RemoteHostWakeFailed,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
|
||||
Reference in New Issue
Block a user