@adhdev/daemon-core 1.0.28-rc.10 → 1.0.28-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,50 @@
1
1
  /**
2
2
  * RF-ROUTER MED family — coordinator-triggered daemon restart.
3
3
  *
4
- * restart_daemon_node exposes the existing dashboard "preview update" path
5
- * (low-family daemon_upgrade: update-to-latest-on-channel + detached restart) as
6
- * a mesh command, so a coordinator can roll a worker daemon onto a freshly
7
- * deployed version without a manual restart round-trip. It mirrors
8
- * fast_forward_mesh_node's remote-forward shape — resolve the target node, and
9
- * if it belongs to a remote daemon forward the command there so the owning
10
- * daemon (not the coordinator) restarts itself — and adds an idle-gate: a node
11
- * with a generating / waiting_approval / starting session is refused so an
12
- * in-flight turn is never killed mid-restart.
4
+ * restart_daemon_node exposes the daemon lifecycle paths (low-family
5
+ * daemon_upgrade / daemon_restart) as a mesh command, so a coordinator can roll
6
+ * a worker daemon onto a freshly deployed version or just reset its state —
7
+ * without a manual restart round-trip. It mirrors fast_forward_mesh_node's
8
+ * remote-forward shape — resolve the target node, and if it belongs to a remote
9
+ * daemon forward the command there so the owning daemon (not the coordinator)
10
+ * restarts itself — and adds an idle-gate: a node with a generating /
11
+ * waiting_approval / starting session is refused so an in-flight turn is never
12
+ * killed mid-restart.
13
13
  *
14
- * v1 reuses daemon_upgrade verbatim rather than adding a restart-only path:
15
- * the goal is "pick up a just-deployed version", which inherently needs the
16
- * npm reinstall the upgrade helper already performs. Already-latest is a no-op
17
- * (no restart), matching the dashboard button.
14
+ * Modes and gate overrides (all opt-in; a bare call behaves exactly like v1):
15
+ *
16
+ * - mode: 'upgrade' (default) reuses daemon_upgrade verbatim update to the
17
+ * latest published version on the resolved channel, then detached-restart.
18
+ * Already-latest is a no-op (no restart), matching the dashboard button.
19
+ * mode: 'restart' uses daemon_restart — a pure re-spawn with no npm
20
+ * reinstall, so it restarts even when already latest and the downtime is
21
+ * just the detached re-spawn. Use it to reset wedged daemon state.
22
+ *
23
+ * - selfOnly: waives blocking sessions that are THIS mesh's own coordinator
24
+ * session (settings.meshCoordinatorFor === meshId). This breaks the
25
+ * structural deadlock where the coordinator can never restart its own
26
+ * daemon because its calling turn is always 'generating'. Other nodes'
27
+ * blocking sessions still refuse. Ad-hoc coordinator sessions launched
28
+ * without mesh_coordinator_launch carry no marker — use force for those.
29
+ *
30
+ * - force: bypasses the idle-gate entirely. Destructive: in-flight turns are
31
+ * killed and the in-memory pendingOutboundQueue is lost (it has no
32
+ * persistence/restore path). Audit-logged.
33
+ *
34
+ * - whenIdle: instead of refusing, schedule the restart and run it
35
+ * automatically once all blocking sessions go idle (the safest path — the
36
+ * pendingOutboundQueue is empty at that point). The schedule expires after
37
+ * timeoutMs (default 30 min). cancelWhenIdle cancels it; every response
38
+ * carries the current schedule under `deferredRestart` for visibility.
39
+ *
40
+ * - killSessionHost: also stop the session-host process, destroying EVERY
41
+ * hosted CLI session (no idle-gate — SessionHostServer.stop kills all PTYs).
42
+ * Mirrors what Windows already does on upgrade (conpty.node lock). Default
43
+ * off; POSIX daemons otherwise keep hosted sessions alive across a restart.
18
44
  */
19
45
  import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
20
46
  import { daemonLifecycleHandlers } from '../low-family/daemon-lifecycle.js';
47
+ import { LOG } from '../../logging/logger.js';
21
48
  import type { CommandRouterResult } from '../router.js';
22
49
  import type { MedFamilyContext, MedFamilyHandler } from './types.js';
23
50
 
@@ -26,18 +53,167 @@ import type { MedFamilyContext, MedFamilyHandler } from './types.js';
26
53
  // daemon-cloud mandatory-update idle-gate (hasBlockingSessionsForMandatoryUpdate).
27
54
  const RESTART_BLOCKING_STATES = new Set(['generating', 'waiting_approval', 'starting']);
28
55
 
29
- function hasBlockingSessions(ctx: MedFamilyContext): boolean {
56
+ const DEFERRED_RESTART_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
57
+ const DEFERRED_RESTART_MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000;
58
+ const DEFERRED_RESTART_POLL_MS = 10_000;
59
+
60
+ type BlockingSession = {
61
+ instanceId: string | null;
62
+ status: string;
63
+ /** True when this session is the calling mesh's own coordinator session. */
64
+ selfCoordinator: boolean;
65
+ };
66
+
67
+ function collectBlockingSessions(ctx: MedFamilyContext, meshId: string): BlockingSession[] {
68
+ const blocking: BlockingSession[] = [];
30
69
  const states = ctx.deps.instanceManager.collectAllStates();
70
+ const consider = (state: any) => {
71
+ const status = String(state?.status || '');
72
+ if (!RESTART_BLOCKING_STATES.has(status)) return;
73
+ blocking.push({
74
+ instanceId: typeof state?.instanceId === 'string' ? state.instanceId : null,
75
+ status,
76
+ // The coordinator marker is stamped by mesh_coordinator_launch and
77
+ // re-stamped on restore (cli-manager.restoreHostedSessions). Ad-hoc
78
+ // coordinator sessions have no marker and are NOT waived by selfOnly.
79
+ selfCoordinator: !!meshId && state?.settings?.meshCoordinatorFor === meshId,
80
+ });
81
+ };
31
82
  for (const state of states) {
32
- if (RESTART_BLOCKING_STATES.has(String(state.status || ''))) return true;
83
+ consider(state);
33
84
  const childStates = 'extensions' in state && Array.isArray((state as any).extensions)
34
85
  ? (state as any).extensions
35
86
  : [];
36
- for (const child of childStates) {
37
- if (RESTART_BLOCKING_STATES.has(String(child?.status || ''))) return true;
38
- }
87
+ for (const child of childStates) consider(child);
88
+ }
89
+ return blocking;
90
+ }
91
+
92
+ type RestartMode = 'upgrade' | 'restart';
93
+
94
+ type PendingDeferredRestart = {
95
+ meshId: string;
96
+ nodeId: string;
97
+ mode: RestartMode;
98
+ killSessionHost: boolean;
99
+ scheduledAt: number;
100
+ expiresAt: number;
101
+ timer: NodeJS.Timeout;
102
+ };
103
+
104
+ // One scheduled restart per daemon process. The record lives on the OWNING
105
+ // daemon (the command is forwarded there before scheduling), which is also the
106
+ // process whose session states the poll inspects.
107
+ let pendingDeferredRestart: PendingDeferredRestart | null = null;
108
+
109
+ function deferredRestartInfo(): Record<string, unknown> | null {
110
+ if (!pendingDeferredRestart) return null;
111
+ return {
112
+ meshId: pendingDeferredRestart.meshId,
113
+ nodeId: pendingDeferredRestart.nodeId,
114
+ mode: pendingDeferredRestart.mode,
115
+ killSessionHost: pendingDeferredRestart.killSessionHost,
116
+ scheduledAt: new Date(pendingDeferredRestart.scheduledAt).toISOString(),
117
+ expiresAt: new Date(pendingDeferredRestart.expiresAt).toISOString(),
118
+ runCondition: 'executes automatically once no session is generating / waiting_approval / starting',
119
+ };
120
+ }
121
+
122
+ function clearPendingDeferredRestart(): void {
123
+ if (pendingDeferredRestart) clearInterval(pendingDeferredRestart.timer);
124
+ pendingDeferredRestart = null;
125
+ }
126
+
127
+ function normalizeRestartMode(value: unknown): RestartMode {
128
+ return value === 'restart' ? 'restart' : 'upgrade';
129
+ }
130
+
131
+ function restartWarnings(args: { killSessionHost: boolean; forced: boolean }): string[] {
132
+ const warnings: string[] = [];
133
+ if (args.forced) {
134
+ warnings.push('forced restart over active sessions — in-flight turns were interrupted and the in-memory pendingOutboundQueue is permanently lost (no persistence/restore path).');
135
+ }
136
+ if (args.killSessionHost) {
137
+ warnings.push('killSessionHost: the session-host process was stopped — ALL hosted CLI sessions on this machine are destroyed (hard refresh).');
138
+ }
139
+ if (process.platform === 'win32') {
140
+ warnings.push('Windows: the daemon restart/upgrade path stops the session-host regardless of options — all hosted sessions terminate.');
141
+ } else {
142
+ warnings.push('POSIX: hosted PTY sessions survive a plain daemon restart and rebind on next boot (unless killSessionHost was set).');
143
+ }
144
+ return warnings;
145
+ }
146
+
147
+ async function executeRestart(ctx: MedFamilyContext, args: any, opts: { forced: boolean }): Promise<CommandRouterResult> {
148
+ const mode = normalizeRestartMode(args?.mode);
149
+ const killSessionHost = args?.killSessionHost === true;
150
+ const result = mode === 'restart'
151
+ ? await daemonLifecycleHandlers.daemon_restart({ deps: ctx.deps }, { killSessionHost })
152
+ : await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
153
+ const restarting = (result as any)?.restarting === true;
154
+ return {
155
+ ...result,
156
+ mode,
157
+ restarted: restarting,
158
+ ...(restarting ? {
159
+ warnings: restartWarnings({ killSessionHost, forced: opts.forced }),
160
+ // MCP IPC has no automatic reconnect/retry — tell the caller when to
161
+ // talk to this daemon again.
162
+ clientHint: 'daemon restarting — retry your next call in ~10s',
163
+ } : {}),
164
+ } as CommandRouterResult;
165
+ }
166
+
167
+ function scheduleDeferredRestart(ctx: MedFamilyContext, args: any, meshId: string, nodeId: string): CommandRouterResult {
168
+ clearPendingDeferredRestart();
169
+ const requestedTimeout = typeof args?.timeoutMs === 'number' && Number.isFinite(args.timeoutMs)
170
+ ? args.timeoutMs
171
+ : DEFERRED_RESTART_DEFAULT_TIMEOUT_MS;
172
+ const timeoutMs = Math.min(Math.max(requestedTimeout, DEFERRED_RESTART_POLL_MS), DEFERRED_RESTART_MAX_TIMEOUT_MS);
173
+ const record: PendingDeferredRestart = {
174
+ meshId,
175
+ nodeId,
176
+ mode: normalizeRestartMode(args?.mode),
177
+ killSessionHost: args?.killSessionHost === true,
178
+ scheduledAt: Date.now(),
179
+ expiresAt: Date.now() + timeoutMs,
180
+ timer: setInterval(() => { void deferredRestartTick(ctx); }, DEFERRED_RESTART_POLL_MS),
181
+ };
182
+ record.timer.unref?.();
183
+ pendingDeferredRestart = record;
184
+ LOG.info('MeshRestart', `Deferred restart scheduled for node ${nodeId} (mode=${record.mode}, expires in ${Math.round(timeoutMs / 60000)}min)`);
185
+ return {
186
+ success: true,
187
+ restarted: false,
188
+ scheduled: true,
189
+ code: 'restart_scheduled_when_idle',
190
+ reason: 'Restart scheduled — it will execute automatically as soon as no session on this daemon is generating / waiting_approval / starting. Running at an idle point also avoids pendingOutboundQueue loss, making this the safest restart path.',
191
+ deferredRestart: deferredRestartInfo(),
192
+ } as CommandRouterResult;
193
+ }
194
+
195
+ async function deferredRestartTick(ctx: MedFamilyContext): Promise<void> {
196
+ const record = pendingDeferredRestart;
197
+ if (!record) return;
198
+ if (Date.now() >= record.expiresAt) {
199
+ LOG.warn('MeshRestart', `Deferred restart for node ${record.nodeId} expired without reaching an idle point; dropping the schedule`);
200
+ clearPendingDeferredRestart();
201
+ return;
202
+ }
203
+ const blocking = collectBlockingSessions(ctx, record.meshId);
204
+ if (blocking.length > 0) return;
205
+ LOG.info('MeshRestart', `Deferred restart for node ${record.nodeId} executing — daemon is idle`);
206
+ clearPendingDeferredRestart();
207
+ try {
208
+ await executeRestart(ctx, {
209
+ meshId: record.meshId,
210
+ nodeId: record.nodeId,
211
+ mode: record.mode,
212
+ killSessionHost: record.killSessionHost,
213
+ }, { forced: false });
214
+ } catch (e: any) {
215
+ LOG.error('MeshRestart', `Deferred restart execution failed: ${e?.message || String(e)}`);
39
216
  }
40
- return false;
41
217
  }
42
218
 
43
219
  export const meshRestartHandlers: Record<string, MedFamilyHandler> = {
@@ -69,24 +245,51 @@ export const meshRestartHandlers: Record<string, MedFamilyHandler> = {
69
245
  return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
70
246
  }
71
247
 
248
+ // Deferred-schedule management on the owning daemon.
249
+ if (args?.cancelWhenIdle === true) {
250
+ const had = pendingDeferredRestart !== null;
251
+ clearPendingDeferredRestart();
252
+ return { success: true, restarted: false, cancelled: had, deferredRestart: null } as CommandRouterResult;
253
+ }
254
+ if (args?.whenIdleStatus === true) {
255
+ return { success: true, restarted: false, deferredRestart: deferredRestartInfo() } as CommandRouterResult;
256
+ }
257
+
72
258
  // Idle-gate: refuse if any session on THIS daemon is mid-turn / awaiting
73
- // approval / starting. The coordinator restarts other (idle) nodes freely;
74
- // restarting the coordinator's OWN daemon is naturally refused while its
75
- // calling turn is 'generating' (accepted v1 limitation — call other nodes
76
- // first, the coordinator last when it has gone idle).
77
- if (hasBlockingSessions(ctx)) {
259
+ // approval / starting unless an explicit opt-in waives it.
260
+ const blocking = collectBlockingSessions(ctx, meshId);
261
+ if (blocking.length > 0) {
262
+ if (args?.force === true) {
263
+ // Bypass the gate entirely. Audit-logged: this kills in-flight
264
+ // turns and drops the unpersisted pendingOutboundQueue.
265
+ LOG.warn('MeshRestart', `force restart over ${blocking.length} blocking session(s): ${blocking.map((b) => `${b.instanceId || '?'}(${b.status})`).join(', ')} — pendingOutboundQueue will be lost`);
266
+ return executeRestart(ctx, args, { forced: true });
267
+ }
268
+ const foreignBlocking = blocking.filter((b) => !b.selfCoordinator);
269
+ if (args?.selfOnly === true && foreignBlocking.length === 0) {
270
+ // Only the calling mesh's own coordinator session is blocking —
271
+ // the structural self-deadlock case. Waive exactly those.
272
+ LOG.info('MeshRestart', `selfOnly restart: waiving ${blocking.length} self-coordinator session(s) for mesh ${meshId}`);
273
+ return executeRestart(ctx, args, { forced: false });
274
+ }
275
+ if (args?.whenIdle === true) {
276
+ return scheduleDeferredRestart(ctx, args, meshId, nodeId);
277
+ }
78
278
  return {
79
279
  success: false,
80
280
  restarted: false,
81
281
  code: 'blocking_sessions',
82
282
  reason: 'Daemon has an active session (generating / waiting_approval / starting); restart refused to avoid interrupting in-flight work. Retry when the node is idle.',
83
- };
283
+ blockingSessions: blocking,
284
+ options: {
285
+ selfOnly: 'waive only this mesh\'s own coordinator session (settings.meshCoordinatorFor === meshId)',
286
+ force: 'bypass the idle-gate entirely — kills in-flight turns and loses the unpersisted pendingOutboundQueue',
287
+ whenIdle: 'schedule the restart to run automatically once the daemon goes idle (safest)',
288
+ },
289
+ deferredRestart: deferredRestartInfo(),
290
+ } as CommandRouterResult;
84
291
  }
85
292
 
86
- // Reuse the battle-tested dashboard "preview update" path: update to the
87
- // latest published version on the resolved channel, then detached-restart.
88
- // Already-latest is a no-op (no restart), matching the dashboard button.
89
- const result = await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
90
- return { ...result, restarted: (result as any)?.restarting === true };
293
+ return executeRestart(ctx, args, { forced: false });
91
294
  },
92
295
  };
@@ -39,6 +39,20 @@ export interface DaemonUpgradeHelperPayload {
39
39
  restartArgv: string[];
40
40
  cwd?: string;
41
41
  sessionHostAppName?: string;
42
+ /**
43
+ * Restart-only mode: wait for the parent daemon to exit, then re-spawn it
44
+ * without running any npm install. Used by daemon_restart (mesh
45
+ * restart_daemon_node mode="restart") to reset daemon state (memory leaks,
46
+ * zombie sessions, wedged internals) with minimal downtime.
47
+ */
48
+ skipInstall?: boolean;
49
+ /**
50
+ * Opt-in hard refresh: also stop the session-host process, which destroys
51
+ * EVERY hosted CLI session (no idle-gate — see SessionHostServer.stop).
52
+ * Mirrors what Windows already does unconditionally on upgrade (conpty.node
53
+ * lock). Default off: POSIX leaves the host running so sessions rebind.
54
+ */
55
+ killSessionHost?: boolean;
42
56
  }
43
57
 
44
58
  export interface CurrentGlobalInstallSurface {
@@ -605,12 +619,25 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
605
619
  // host running on POSIX; on the next boot `ensureSessionHostReady()` reuses
606
620
  // the still-listening socket and `cliManager.restoreHostedSessions()` rebinds
607
621
  // the live runtimes. Windows keeps the kill unchanged.
608
- if (process.platform === 'win32') {
622
+ // killSessionHost is an explicit opt-in hard refresh (mesh restart_daemon_node
623
+ // kill_session_host) that forces the same teardown on any platform.
624
+ if (process.platform === 'win32' || payload.killSessionHost === true) {
609
625
  await stopSessionHostProcesses(sessionHostAppName);
610
626
  } else {
611
627
  appendUpgradeLog('POSIX — session-host left running (survives upgrade; sessions rebind on next boot)');
612
628
  }
613
629
  removeDaemonPidFile();
630
+
631
+ // Restart-only mode (daemon_restart): the parent has exited and the pid file
632
+ // is gone — re-spawn the daemon as-is. No npm install, no prefix rotation, so
633
+ // there is no Windows lock hazard and downtime is just the re-spawn.
634
+ if (payload.skipInstall) {
635
+ appendUpgradeLog('Restart-only mode — package install skipped, re-spawning daemon');
636
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
637
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
638
+ return;
639
+ }
640
+
614
641
  // Scope the Windows atomic-upgrade layout to THIS daemon's instance so
615
642
  // `adhdev-preview update` rotates only the preview prefix/pointer/tools tree
616
643
  // and never touches the stable install (and vice-versa). Stable / no-override
@@ -758,6 +785,11 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
758
785
  appendUpgradeLog('Post-install staging cleanup complete');
759
786
  }
760
787
 
788
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
789
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
790
+ }
791
+
792
+ function spawnDetachedDaemonRestart(restartArgv: string[], cwd?: string): void {
761
793
  if (restartArgv.length > 0) {
762
794
  const env = { ...process.env };
763
795
  delete env[UPGRADE_HELPER_ENV];
@@ -766,14 +798,13 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
766
798
  detached: true,
767
799
  stdio: 'ignore',
768
800
  windowsHide: true,
769
- cwd: payload.cwd || process.cwd(),
801
+ cwd: cwd || process.cwd(),
770
802
  env,
771
803
  });
772
804
  child.unref();
773
805
  } else {
774
806
  appendUpgradeLog('No restart argv provided; upgrade completed without restart');
775
807
  }
776
- try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
777
808
  }
778
809
 
779
810
  export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {