@adhdev/daemon-core 1.0.17 → 1.0.18-rc.10

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.
Files changed (36) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  3. package/dist/commands/process-lifecycle.d.ts +43 -0
  4. package/dist/commands/upgrade-helper.d.ts +7 -0
  5. package/dist/commands/windows-atomic-upgrade.d.ts +63 -0
  6. package/dist/index.js +5818 -4496
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5853 -4525
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  11. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  12. package/dist/mesh/mesh-ledger.d.ts +28 -1
  13. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  14. package/dist/providers/cli-provider-instance-types.d.ts +2 -0
  15. package/dist/providers/cli-provider-instance.d.ts +2 -0
  16. package/package.json +3 -3
  17. package/src/boot/daemon-lifecycle.ts +10 -0
  18. package/src/cli-adapters/cli-state-engine.ts +204 -3
  19. package/src/cli-adapters/provider-cli-adapter.ts +39 -5
  20. package/src/cli-adapters/pty-transport.d.ts +2 -1
  21. package/src/cli-adapters/pty-transport.ts +1 -1
  22. package/src/cli-adapters/session-host-transport.ts +8 -3
  23. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  24. package/src/commands/process-lifecycle.ts +248 -0
  25. package/src/commands/upgrade-helper.ts +146 -79
  26. package/src/commands/windows-atomic-upgrade.ts +631 -0
  27. package/src/config/mesh-json-config.ts +8 -0
  28. package/src/mesh/coordinator-prompt.ts +256 -10
  29. package/src/mesh/mesh-disk-retention.ts +370 -0
  30. package/src/mesh/mesh-ledger.ts +72 -0
  31. package/src/mesh/mesh-queue-assignment.ts +126 -1
  32. package/src/mesh/mesh-reconcile-loop.ts +49 -0
  33. package/src/mesh/mesh-runtime-store.ts +21 -0
  34. package/src/providers/cli-provider-instance-types.ts +25 -0
  35. package/src/providers/cli-provider-instance.ts +116 -0
  36. package/src/session-host/managed-host.ts +34 -0
@@ -3,6 +3,19 @@ import { spawn } from 'child_process';
3
3
  import * as fs from 'fs';
4
4
  import * as os from 'os';
5
5
  import * as path from 'path';
6
+ import {
7
+ ADHDEV_OWNED_MARKERS,
8
+ createDefaultWindowsAtomicHooks,
9
+ findPortableNode22,
10
+ performWindowsAtomicUpgrade,
11
+ resolveWindowsInstallerLayout,
12
+ } from './windows-atomic-upgrade.js';
13
+ import {
14
+ getProcessCommandLine,
15
+ killProcess,
16
+ stopOwnedProcessesForPrefixes,
17
+ waitForPidExit,
18
+ } from './process-lifecycle.js';
6
19
 
7
20
  const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
8
21
 
@@ -32,17 +45,16 @@ export interface PinnedGlobalInstallCommand {
32
45
 
33
46
  export type NpmExecOptions = { shell: boolean; windowsHide?: boolean };
34
47
 
35
- function getUpgradeLogPath(): string {
36
- const home = os.homedir();
48
+ function getUpgradeLogPath(home: string = os.homedir()): string {
37
49
  const dir = path.join(home, '.adhdev');
38
50
  fs.mkdirSync(dir, { recursive: true });
39
51
  return path.join(dir, 'daemon-upgrade.log');
40
52
  }
41
53
 
42
- function appendUpgradeLog(message: string): void {
54
+ function appendUpgradeLog(message: string, homeDir: string = os.homedir()): void {
43
55
  const line = `[${new Date().toISOString()}] ${message}\n`;
44
56
  try {
45
- fs.appendFileSync(getUpgradeLogPath(), line, 'utf8');
57
+ fs.appendFileSync(getUpgradeLogPath(homeDir), line, 'utf8');
46
58
  } catch {
47
59
  // noop
48
60
  }
@@ -132,19 +144,68 @@ function resolveInstallPrefixFromPackageRoot(packageRoot: string, packageName: s
132
144
  return maybeLibDir;
133
145
  }
134
146
 
147
+ // True when `prefix` is the bin dir of a portable Node 22 the installer manages
148
+ // under ~/.adhdev/tools/node22/<node-vX>/. A `npm i -g adhdev` run while that
149
+ // portable node is the active `node` installs adhdev into node's own default
150
+ // global prefix (= that dir) — a "legacy node22-prefix" install that lives at
151
+ // the FRONT of PATH (Enable-NodePath prepends node22) and shadows the canonical
152
+ // dispatcher shims in ~/.adhdev/npm-global. Because self-upgrade reuses the
153
+ // running prefix, that install then re-installs into the same node22 dir forever
154
+ // and never converts to the dispatcher. Detecting it lets us force convergence.
155
+ function isPortableNode22Prefix(prefix: string | null, homeDir: string): boolean {
156
+ if (!prefix) return false;
157
+ const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
158
+ const normalizedPrefix = path.resolve(prefix).replace(/[\\/]+$/, '').toLowerCase();
159
+ const normalizedRoot = path.resolve(portableRoot).replace(/[\\/]+$/, '').toLowerCase();
160
+ return normalizedPrefix === normalizedRoot || normalizedPrefix.startsWith(`${normalizedRoot}${path.sep.toLowerCase()}`);
161
+ }
162
+
163
+ // Redirect a legacy node22-prefix install to the canonical dispatcher install
164
+ // root so resolveWindowsInstallerLayout accepts it and the atomic-upgrade
165
+ // publishes the ~/.adhdev/npm-global pointer + shims. Prefer the version the
166
+ // dispatcher pointer already names (so we sit on the real active dispatcher
167
+ // prefix); otherwise synthesize a stable migration sentinel under npm-installs.
168
+ // resolveWindowsInstallerLayout only requires a `npm-installs/version-*` path —
169
+ // performWindowsAtomicUpgrade stages a fresh version- prefix of its own and uses
170
+ // this only as the "old prefix" to stop/clean, so a non-existent path is a no-op.
171
+ function canonicalDispatcherInstallPrefix(homeDir: string): string {
172
+ const installRoot = path.join(homeDir, '.adhdev', 'npm-installs');
173
+ const pointerPath = path.join(homeDir, '.adhdev', 'npm-global', '.adhdev-current');
174
+ try {
175
+ const activeVersion = fs.readFileSync(pointerPath, 'utf8').trim();
176
+ if (activeVersion.startsWith('version-')) return path.join(installRoot, activeVersion);
177
+ } catch {
178
+ // No dispatcher pointer yet (first migration off the legacy layout).
179
+ }
180
+ return path.join(installRoot, 'version-legacy-migrate');
181
+ }
182
+
135
183
  export function resolveCurrentGlobalInstallSurface(options: {
136
184
  packageName: string;
137
185
  currentCliPath?: string;
138
186
  nodeExecutable?: string;
139
187
  platform?: NodeJS.Platform;
188
+ homeDir?: string;
140
189
  }): CurrentGlobalInstallSurface {
141
190
  const packageRoot = findCurrentPackageRoot(options.currentCliPath || process.argv[1], options.packageName);
142
191
  const npmInvocation = resolveSiblingNpmInvocation(options.nodeExecutable || process.execPath, options.platform);
192
+ const platform = options.platform || process.platform;
193
+ const homeDir = options.homeDir || os.homedir();
194
+ let installPrefix = packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null;
195
+ // FIX C: on Windows, never let a self-upgrade perpetuate the legacy
196
+ // node22-prefix install. If the running adhdev lives under ~/.adhdev/tools/
197
+ // node22, force the install onto the canonical dispatcher prefix so the update
198
+ // converges to the ~/.adhdev/npm-global pointer + shims. Scoped to win32 AND a
199
+ // tools/node22 prefix so npm-linked dev / standalone / real dispatcher installs
200
+ // are untouched.
201
+ if (platform === 'win32' && isPortableNode22Prefix(installPrefix, homeDir)) {
202
+ installPrefix = canonicalDispatcherInstallPrefix(homeDir);
203
+ }
143
204
  return {
144
205
  npmExecutable: npmInvocation.executable,
145
206
  npmArgsPrefix: npmInvocation.argsPrefix,
146
207
  packageRoot,
147
- installPrefix: packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null,
208
+ installPrefix,
148
209
  execOptions: npmInvocation.execOptions,
149
210
  };
150
211
  }
@@ -230,77 +291,11 @@ export function execNpmCommandSync(
230
291
  );
231
292
  }
232
293
 
233
- function killPid(pid: number): boolean {
234
- try {
235
- if (process.platform === 'win32') {
236
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
237
- } else {
238
- process.kill(pid, 'SIGTERM');
239
- }
240
- return true;
241
- } catch {
242
- return false;
243
- }
244
- }
245
-
246
- function getWindowsProcessCommandLine(pid: number): string | null {
247
- const pidFilter = `ProcessId=${pid}`;
248
- try {
249
- const psOut = execFileSync('powershell.exe', [
250
- '-NoProfile',
251
- '-NonInteractive',
252
- '-ExecutionPolicy', 'Bypass',
253
- '-Command',
254
- `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
255
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
256
- if (psOut) return psOut;
257
- } catch {
258
- // fall through to wmic fallback
259
- }
260
-
261
- try {
262
- const wmicOut = execFileSync('wmic', [
263
- 'process', 'where', pidFilter, 'get', 'CommandLine',
264
- ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
265
- if (wmicOut) return wmicOut;
266
- } catch {
267
- // noop
268
- }
269
- return null;
270
- }
271
-
272
- function getProcessCommandLine(pid: number): string | null {
273
- if (!Number.isFinite(pid) || pid <= 0) return null;
274
- if (process.platform === 'win32') return getWindowsProcessCommandLine(pid);
275
- try {
276
- const text = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
277
- encoding: 'utf8',
278
- timeout: 3000,
279
- stdio: ['ignore', 'pipe', 'ignore'],
280
- }).trim();
281
- return text || null;
282
- } catch {
283
- return null;
284
- }
285
- }
286
-
287
294
  function isManagedSessionHostPid(pid: number): boolean {
288
295
  const commandLine = getProcessCommandLine(pid);
289
296
  return !!commandLine && /session-host-daemon/i.test(commandLine);
290
297
  }
291
298
 
292
- async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
293
- const start = Date.now();
294
- while (Date.now() - start < timeoutMs) {
295
- try {
296
- process.kill(pid, 0);
297
- await new Promise((resolve) => setTimeout(resolve, 250));
298
- } catch {
299
- return;
300
- }
301
- }
302
- }
303
-
304
299
  export async function stopSessionHostProcesses(appName: string): Promise<void> {
305
300
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
306
301
  let killedPid: number | null = null;
@@ -308,7 +303,7 @@ export async function stopSessionHostProcesses(appName: string): Promise<void> {
308
303
  if (fs.existsSync(pidFile)) {
309
304
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
310
305
  if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
311
- if (killPid(pid)) killedPid = pid;
306
+ if (killProcess(pid)) killedPid = pid;
312
307
  }
313
308
  }
314
309
  } catch {
@@ -425,7 +420,7 @@ export async function stopForeignNativeAddonHolders(
425
420
  appendUpgradeLog(
426
421
  `Foreign native-addon holder found: pid ${holder.pid}${holder.commandLine ? ` — ${holder.commandLine}` : ''}`,
427
422
  );
428
- const killed = killPid(holder.pid);
423
+ const killed = killProcess(holder.pid);
429
424
  if (killed) {
430
425
  await waitForPidExit(holder.pid, 15000);
431
426
  appendUpgradeLog(`Terminated foreign native-addon holder pid ${holder.pid}`);
@@ -437,8 +432,7 @@ export async function stopForeignNativeAddonHolders(
437
432
  return results;
438
433
  }
439
434
 
440
- function getUpgradeFailureNoticePath(): string {
441
- const home = os.homedir();
435
+ function getUpgradeFailureNoticePath(home: string = os.homedir()): string {
442
436
  const dir = path.join(home, '.adhdev');
443
437
  try {
444
438
  fs.mkdirSync(dir, { recursive: true });
@@ -459,11 +453,11 @@ function buildManualRecoveryCommand(installCommand: PinnedGlobalInstallCommand):
459
453
  * log line: the pids/commandlines still holding the lock and a paste-ready
460
454
  * recovery command. Written to a stable path the CLI can surface on next boot.
461
455
  */
462
- function emitUpgradeFailureNotice(lines: string[]): void {
456
+ export function emitUpgradeFailureNotice(lines: string[], homeDir: string = os.homedir()): void {
463
457
  const body = lines.join('\n');
464
- appendUpgradeLog(`Upgrade blocked — user action required:\n${body}`);
458
+ appendUpgradeLog(`Upgrade blocked — user action required:\n${body}`, homeDir);
465
459
  try {
466
- fs.writeFileSync(getUpgradeFailureNoticePath(), `[${new Date().toISOString()}]\n${body}\n`, 'utf8');
460
+ fs.writeFileSync(getUpgradeFailureNoticePath(homeDir), `[${new Date().toISOString()}]\n${body}\n`, 'utf8');
467
461
  } catch {
468
462
  // noop
469
463
  }
@@ -582,6 +576,73 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
582
576
 
583
577
  await stopSessionHostProcesses(sessionHostAppName);
584
578
  removeDaemonPidFile();
579
+ const windowsInstallerLayout = resolveWindowsInstallerLayout({
580
+ homeDir: os.homedir(),
581
+ installPrefix: installCommand.surface.installPrefix,
582
+ });
583
+ if (windowsInstallerLayout) {
584
+ const portableNode = findPortableNode22(os.homedir());
585
+ if (!portableNode) {
586
+ throw new Error('installer-managed Windows update requires the portable Node.js 22 runtime');
587
+ }
588
+ const npmCliPath = path.join(path.dirname(portableNode), 'node_modules', 'npm', 'bin', 'npm-cli.js');
589
+ if (!fs.existsSync(npmCliPath)) {
590
+ throw new Error(`portable Node.js 22 npm CLI is missing: ${npmCliPath}`);
591
+ }
592
+ appendUpgradeLog(`Installer-managed pointer layout detected; active prefix will remain untouched: ${windowsInstallerLayout.activePrefix}`);
593
+
594
+ // Terminate any ADHDev-owned process still executing from the current active
595
+ // prefix or the legacy stable shim tree before activation. The parent daemon
596
+ // and this helper itself are excluded: the parent is already exiting, and the
597
+ // helper must survive to complete the upgrade.
598
+ const upgradePids = [process.pid, payload.parentPid].filter((n): n is number => Number.isFinite(n) && n > 0);
599
+ const preStop = await stopOwnedProcessesForPrefixes({
600
+ prefixes: [windowsInstallerLayout.activePrefix, windowsInstallerLayout.stablePrefix],
601
+ excludePids: upgradePids,
602
+ markers: Array.from(ADHDEV_OWNED_MARKERS),
603
+ waitMs: 15_000,
604
+ log: appendUpgradeLog,
605
+ });
606
+ if (preStop.survivors.length > 0) {
607
+ throw new Error(
608
+ `Cannot upgrade: owned processes still running under current prefix: ${preStop.survivors.map((s) => s.pid).join(', ')}`
609
+ );
610
+ }
611
+
612
+ try {
613
+ await performWindowsAtomicUpgrade({
614
+ layout: windowsInstallerLayout,
615
+ packageName: payload.packageName,
616
+ targetVersion: payload.targetVersion,
617
+ portableNode,
618
+ excludePids: upgradePids,
619
+ hooks: createDefaultWindowsAtomicHooks({
620
+ packageName: payload.packageName,
621
+ targetVersion: payload.targetVersion,
622
+ npmCliPath,
623
+ restartArgv,
624
+ cwd: payload.cwd || process.cwd(),
625
+ env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== UPGRADE_HELPER_ENV)),
626
+ log: appendUpgradeLog,
627
+ }),
628
+ });
629
+ } catch (error: any) {
630
+ // performWindowsAtomicUpgrade already rolled the pointer/shims back to the
631
+ // prior version before rethrowing. Without a durable notice that rollback
632
+ // was silent — the daemon simply kept running the old version (the rc.6
633
+ // stuck-upgrade defect). Leave an actionable last-error file naming the
634
+ // target that failed its health/version gate.
635
+ emitUpgradeFailureNotice([
636
+ `adhdev ${payload.packageName}@${payload.targetVersion} upgrade failed and was rolled back: ${error?.message || String(error)}`,
637
+ `Previous version preserved (active prefix: ${windowsInstallerLayout.activePrefix}).`,
638
+ 'See daemon-upgrade.log for the full install/health trace. The next daemon start will retry.',
639
+ ]);
640
+ throw error;
641
+ }
642
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
643
+ appendUpgradeLog('Installer-managed Windows atomic upgrade completed');
644
+ return;
645
+ }
585
646
  // Kill any *foreign* process still holding this install's conpty.node mapped
586
647
  // (the session-host stop above only covers the single managed pid). Do this
587
648
  // BEFORE the staging GC so the just-released file can also be cleaned up now
@@ -671,6 +732,7 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
671
732
  } else {
672
733
  appendUpgradeLog('No restart argv provided; upgrade completed without restart');
673
734
  }
735
+ try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
674
736
  }
675
737
 
676
738
  export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {
@@ -683,7 +745,12 @@ export async function maybeRunDaemonUpgradeHelperFromEnv(): Promise<boolean> {
683
745
  await runDaemonUpgradeHelper(payload);
684
746
  process.exit(0);
685
747
  } catch (error: any) {
686
- appendUpgradeLog(`Upgrade helper failed: ${error?.stack || error?.message || String(error)}`);
748
+ const detail = error?.stack || error?.message || String(error);
749
+ appendUpgradeLog(`Upgrade helper failed: ${detail}`);
750
+ emitUpgradeFailureNotice([
751
+ `adhdev upgrade failed: ${error?.message || String(error)}`,
752
+ `See ${getUpgradeLogPath()} for details. The previous installer-managed version was preserved or restored when available.`,
753
+ ]);
687
754
  process.exit(1);
688
755
  }
689
756
  }