@quolu/lattice 0.79.3 → 0.79.4

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.
@@ -0,0 +1,180 @@
1
+ // This module deliberately has no dashboard, bridge or sensor dependency.
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { createLifecycleRun, lifecycleError, readLifecycleRuns, recordLifecyclePhase,
6
+ writeLifecycleRun } from './dashboard-lifecycle-journal.mjs';
7
+ import { observeDashboardProcess, observedDashboardExit,
8
+ terminateOwnedWindowsDashboard } from './dashboard-process-control.mjs';
9
+
10
+ function notify(message) {
11
+ if (process.connected) process.send(message, () => {});
12
+ }
13
+
14
+ /** The sole owner of one child: spawn, wait, forward stop, persist receipts. */
15
+ export async function superviseDashboard({ executable = process.execPath, args, env,
16
+ root, runId, graceMs = 3_000 }) {
17
+ const run = (await readLifecycleRuns(root)).find((value) => value.run_id === runId);
18
+ if (!run || run.state !== 'active') throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
19
+ run.supervisor = await observeDashboardProcess(process.pid);
20
+ await writeLifecycleRun(root, run);
21
+ // libuv assigns non-detached Windows children to its parent's kill-on-close Job.
22
+ // The observer-loss contract keeps this child serving after supervisor death.
23
+ const child = spawn(executable, args, { env, windowsHide: true, detached: process.platform === 'win32',
24
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
25
+ child.once('spawn', () => { run.child_spawn_observed_at = new Date().toISOString(); });
26
+ recordLifecyclePhase(run, 'import');
27
+ const digest = createHash('sha256');
28
+ let writeQueue = Promise.resolve();
29
+ let persistenceFailure = null;
30
+ let stopTimer = null;
31
+ let controlPending = Promise.resolve();
32
+ let finished = false;
33
+ const persist = () => {
34
+ writeQueue = writeQueue.then(() => writeLifecycleRun(root, run));
35
+ writeQueue.catch((error) => {
36
+ if (persistenceFailure) return;
37
+ persistenceFailure = error;
38
+ notify({ type: 'supervisor_error', code: 'DASHBOARD_LIFECYCLE_WRITE_FAILED' });
39
+ requestStop('receipt_failure');
40
+ });
41
+ return writeQueue;
42
+ };
43
+ const requestStop = (reason, force = false) => {
44
+ if (finished || (run.termination_request !== null && !force)) return;
45
+ if (run.termination_request === null) {
46
+ run.termination_request = { at: new Date().toISOString(), reason, mechanism: 'ipc_shutdown',
47
+ child: run.child, os_request: null };
48
+ recordLifecyclePhase(run, 'shutdown_request');
49
+ if (!persistenceFailure) persist();
50
+ if (child.connected) child.send({ type: 'dashboard_shutdown' }, () => {});
51
+ }
52
+ clearTimeout(stopTimer);
53
+ stopTimer = setTimeout(() => {
54
+ if (finished) return;
55
+ controlPending = (async () => {
56
+ if (process.platform === 'win32') {
57
+ const result = await terminateOwnedWindowsDashboard(run.child);
58
+ run.termination_request.os_request = { mechanism: 'TerminateProcess',
59
+ requested_exit_code: result.requested_exit_code,
60
+ api_succeeded: result.termination_requested,
61
+ actual_exit_code: result.actual_exit_code, source: result.source };
62
+ } else {
63
+ run.termination_request.os_request = { mechanism: 'child_signal', signal: 'SIGKILL',
64
+ api_succeeded: child.kill('SIGKILL') };
65
+ }
66
+ if (!persistenceFailure) await persist();
67
+ })();
68
+ controlPending.catch((error) => {
69
+ notify({ type: 'supervisor_error', code: error.code ?? 'DASHBOARD_CHILD_CONTROL_FAILED' });
70
+ // Cannot authenticate/control the child: leave it available, with unknown exit.
71
+ process.exitCode = 1;
72
+ });
73
+ }, force ? 0 : graceMs);
74
+ };
75
+ const term = () => requestStop('supervisor_SIGTERM');
76
+ const interrupt = () => requestStop('supervisor_SIGINT');
77
+ const parentMessage = (message) => {
78
+ if (message?.type === 'dashboard_supervisor_stop') requestStop('owner_stop', message.force === true);
79
+ };
80
+ process.on('SIGTERM', term);
81
+ process.on('SIGINT', interrupt);
82
+ process.on('message', parentMessage);
83
+ // A parent IPC disconnect is expected when ensure exits. The child remains serving.
84
+ process.channel?.unref();
85
+ child.stderr.on('data', (bytes) => {
86
+ run.stderr.bytes = Math.min(Number.MAX_SAFE_INTEGER, run.stderr.bytes + bytes.length);
87
+ const remaining = 65_536 - run.stderr.hashed_bytes;
88
+ if (remaining > 0) {
89
+ const part = bytes.subarray(0, remaining);
90
+ digest.update(part); run.stderr.hashed_bytes += part.length;
91
+ }
92
+ run.stderr.truncated = run.stderr.bytes > run.stderr.hashed_bytes;
93
+ });
94
+ child.on('message', (message) => {
95
+ if (message?.type === 'dashboard_phase' && recordLifecyclePhase(run, message.phase)) persist();
96
+ });
97
+ let spawnFailure = false;
98
+ child.once('error', () => { spawnFailure = true; });
99
+ const ended = new Promise((resolve) => child.once('close', (code, signal) =>
100
+ resolve({ code, signal, observedAt: new Date().toISOString() })));
101
+ if (Number.isSafeInteger(child.pid)) {
102
+ run.child = await observeDashboardProcess(child.pid) ?? { pid: child.pid, birth: null };
103
+ await persist();
104
+ notify({ type: 'dashboard_child_started', pid: child.pid, run_id: runId });
105
+ }
106
+ const exit = await ended;
107
+ finished = true;
108
+ clearTimeout(stopTimer);
109
+ try { await controlPending; } catch { /* Typed control failure was delivered above; exit is independently observed. */ }
110
+ run.stderr.sha256 = digest.digest('hex');
111
+ run.exit_observed_at = exit.observedAt;
112
+ run.state = 'closed';
113
+ run.observed_exit = spawnFailure ? { status: 'unknown', reason: 'spawn_failed' }
114
+ : observedDashboardExit(exit.code, exit.signal);
115
+ if (process.platform === 'win32' && run.termination_request?.os_request?.api_succeeded) {
116
+ run.observed_exit.origin = 'owned_terminate_request';
117
+ }
118
+ // phase is the last child/control stage, not an invented successful close.
119
+ try {
120
+ if (persistenceFailure) throw persistenceFailure;
121
+ await persist();
122
+ notify({ type: 'dashboard_child_exited', pid: child.pid, code: exit.code, signal: exit.signal });
123
+ } finally {
124
+ process.off('SIGTERM', term); process.off('SIGINT', interrupt);
125
+ process.off('message', parentMessage);
126
+ if (process.connected) process.disconnect();
127
+ }
128
+ process.exitCode = run.observed_exit.status !== 'observed' ? 1
129
+ : process.platform === 'win32' ? run.observed_exit.uint32
130
+ : run.observed_exit.code ?? 1;
131
+ return run;
132
+ }
133
+
134
+ /** ChildProcess-compatible seam used by the existing registry readiness/rollback. */
135
+ export async function spawnSupervisedDashboard(executable, args, options) {
136
+ const root = options.env.LATTICE_DASHBOARD_RUNTIME_DIR;
137
+ const run = await createLifecycleRun(root, options.version);
138
+ const supervisor = spawn(executable,
139
+ [path.resolve(import.meta.dirname, '../bin/lattice-dashboard-supervisor.mjs'), root], {
140
+ detached: true, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
141
+ env: { ...options.env, LATTICE_DASHBOARD_LIFECYCLE_RUN: run.run_id },
142
+ });
143
+ // The daemon command is fixed by the product entry, never accepted from env/IPC.
144
+ const facade = { pid: null, exitCode: null, signalCode: null, lifecycleRunId: run.run_id,
145
+ unref() { supervisor.unref(); supervisor.channel?.unref(); },
146
+ kill(signal = 'SIGTERM') {
147
+ if (supervisor.connected) supervisor.send({ type: 'dashboard_supervisor_stop',
148
+ force: signal === 'SIGKILL' }, () => {});
149
+ return supervisor.connected;
150
+ },
151
+ };
152
+ supervisor.once('exit', (code, signal) => {
153
+ facade.supervisorExit = { code, signal };
154
+ if (facade.exitCode === null && facade.signalCode === null) {
155
+ facade.observationError = lifecycleError('DASHBOARD_SUPERVISOR_LOST');
156
+ }
157
+ });
158
+ await new Promise((resolve, reject) => {
159
+ const timer = setTimeout(() => {
160
+ facade.kill(); reject(lifecycleError('DASHBOARD_SUPERVISOR_START_TIMEOUT'));
161
+ }, 15_000);
162
+ const fail = (error) => { clearTimeout(timer); reject(error); };
163
+ supervisor.once('error', fail);
164
+ supervisor.once('exit', () => fail(lifecycleError('DASHBOARD_SUPERVISOR_UNAVAILABLE')));
165
+ supervisor.on('message', (message) => {
166
+ if (message?.type === 'dashboard_child_exited' && message.pid === facade.pid) {
167
+ facade.exitCode = message.code; facade.signalCode = message.signal;
168
+ }
169
+ if (message?.type === 'supervisor_error') {
170
+ facade.observationError = lifecycleError(message.code);
171
+ fail(facade.observationError);
172
+ }
173
+ if (message?.type === 'dashboard_child_started' && message.run_id === run.run_id
174
+ && Number.isSafeInteger(message.pid) && message.pid > 0) {
175
+ clearTimeout(timer); facade.pid = message.pid; resolve();
176
+ }
177
+ });
178
+ });
179
+ return facade;
180
+ }
@@ -0,0 +1,40 @@
1
+ param(
2
+ [Parameter(Mandatory=$true)][int]$ChildPid,
3
+ [Parameter(Mandatory=$true)][string]$BirthFileTime,
4
+ [uint32]$RequestedExitCode = 1
5
+ )
6
+ $ErrorActionPreference = 'Stop'
7
+ Add-Type -TypeDefinition @'
8
+ using System;
9
+ using System.Runtime.InteropServices;
10
+ public static class DashboardOwnedProcess {
11
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
12
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr h, out long birth, out long exit, out long kernel, out long user);
13
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr h, uint exit);
14
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern uint WaitForSingleObject(IntPtr h, uint ms);
15
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetExitCodeProcess(IntPtr h, out uint exit);
16
+ [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
17
+ }
18
+ '@
19
+ # PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE | PROCESS_TERMINATE. No debug privilege.
20
+ $handle = [DashboardOwnedProcess]::OpenProcess(0x101001, $false, $ChildPid)
21
+ if ($handle -eq [IntPtr]::Zero) { throw 'DASHBOARD_CHILD_OPEN_FAILED' }
22
+ try {
23
+ [long]$birth=0; [long]$ended=0; [long]$kernel=0; [long]$user=0
24
+ if (-not [DashboardOwnedProcess]::GetProcessTimes($handle,[ref]$birth,[ref]$ended,[ref]$kernel,[ref]$user)) { throw 'DASHBOARD_CHILD_BIRTH_FAILED' }
25
+ if ($birth.ToString() -cne $BirthFileTime) { throw 'DASHBOARD_CHILD_IDENTITY_MISMATCH' }
26
+ $alreadyExited = [DashboardOwnedProcess]::WaitForSingleObject($handle,0) -eq 0
27
+ $requested = $false
28
+ if (-not $alreadyExited) {
29
+ if (-not [DashboardOwnedProcess]::TerminateProcess($handle,$RequestedExitCode)) {
30
+ # A racing natural exit may win. Verify it with this handle, never invent the requested value.
31
+ if ([DashboardOwnedProcess]::WaitForSingleObject($handle,0) -ne 0) { throw 'DASHBOARD_CHILD_TERMINATE_FAILED' }
32
+ } else { $requested = $true }
33
+ }
34
+ if ([DashboardOwnedProcess]::WaitForSingleObject($handle,5000) -ne 0) { throw 'DASHBOARD_CHILD_WAIT_FAILED' }
35
+ [uint32]$actual=0
36
+ if (-not [DashboardOwnedProcess]::GetExitCodeProcess($handle,[ref]$actual)) { throw 'DASHBOARD_CHILD_EXIT_READ_FAILED' }
37
+ [ordered]@{schema='lattice.dashboard_process_control.v1';pid=$ChildPid;birth_filetime=$BirthFileTime;
38
+ termination_requested=$requested;requested_exit_code=$RequestedExitCode;actual_exit_code=$actual;
39
+ actual_exit_hex=('0x{0:X8}' -f $actual);source='GetExitCodeProcess'} | ConvertTo-Json -Compress
40
+ } finally { [void][DashboardOwnedProcess]::CloseHandle($handle) }
@@ -1,6 +1,7 @@
1
1
  import { readFile, lstat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { evaluateNodeVersionGuard } from './node-version-guard.mjs';
4
5
 
5
6
  /**
6
7
  * dotagents工場の native factory diagnostics 面(親plan L6要件)。
@@ -17,7 +18,7 @@ const MAX_DETAIL_LENGTH = 256;
17
18
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
18
19
  const UNKNOWN_VERSION_SENTINEL = '0.0.0-unknown';
19
20
  const NODE_VERSION_PATTERN = /^v(\d+)\.(\d+)\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
20
- const ENGINES_FLOOR_RANGE_PATTERN = /^(>=(\d+)(?:\.(\d+))?(?:\.\d+)?)(?: +<25(?:\.0\.0)? +\|\| +>=26(?:\.0\.0)?)$/;
21
+ const ENGINE_COMPARATOR = /^(>=|<)(\d+)(?:\.(\d+))?(?:\.(\d+))?$/u;
21
22
  const CHECK_IDS = Object.freeze([
22
23
  'package_version',
23
24
  'node_runtime',
@@ -70,18 +71,30 @@ function checkNodeRuntime(nodeVersion, enginesNode) {
70
71
  if (!runtime) {
71
72
  return check('node_runtime', false, 'node version is unreadable');
72
73
  }
73
- const floor = ENGINES_FLOOR_RANGE_PATTERN.exec(enginesNode ?? '');
74
- if (!floor) {
75
- return check('node_runtime', false, 'package.json engines.node floor is unreadable');
74
+ // Lattice declares disjunctions of numeric >= floors and optional < ceilings.
75
+ // Parse the entire declared contract; a floor-only check silently accepts holes.
76
+ const alternatives = typeof enginesNode === 'string' ? enginesNode.split(' || ') : [];
77
+ const ranges = alternatives.map((range) => range.split(' ').map((part) => ENGINE_COMPARATOR.exec(part)));
78
+ if (ranges.length === 0 || ranges.some((range) => range.length < 1 || range.length > 2
79
+ || range.some((part) => part === null) || range[0][1] !== '>='
80
+ || (range.length === 2 && range[1][1] !== '<'))) {
81
+ return check('node_runtime', false, 'package.json engines.node range is unreadable');
76
82
  }
77
- const [major, minor] = [Number(runtime[1]), Number(runtime[2])];
78
- const [floorMajor, floorMinor] = [Number(floor[2]), Number(floor[3] ?? '0')];
79
- const satisfied = major > floorMajor || (major === floorMajor && minor >= floorMinor);
83
+ const actual = nodeVersion.slice(1).split(/[.+-]/u).slice(0, 3).map(Number);
84
+ const compare = (bound) => {
85
+ const version = bound.slice(2, 5).map((part) => Number(part ?? 0));
86
+ for (let index = 0; index < 3; index += 1) {
87
+ if (actual[index] !== version[index]) return actual[index] < version[index] ? -1 : 1;
88
+ }
89
+ return 0;
90
+ };
91
+ const satisfied = ranges.some((range) => range.every((bound) =>
92
+ bound[1] === '>=' ? compare(bound) >= 0 : compare(bound) < 0))
93
+ && !evaluateNodeVersionGuard(nodeVersion.slice(1), false).blocked;
80
94
  return check(
81
95
  'node_runtime',
82
96
  satisfied,
83
- satisfied ? `${nodeVersion} satisfies engines.node floor ${floor[1]}`
84
- : `${nodeVersion} is below engines.node floor ${floor[1]}`,
97
+ `${nodeVersion} ${satisfied ? 'satisfies' : 'does not satisfy'} engines.node ${enginesNode}`,
85
98
  );
86
99
  }
87
100
 
@@ -1,44 +1,5 @@
1
- /**
2
- * lattice-mcp bin向けNode versionガード(ADR 0049 wave2レビューでのスコープ外
3
- * 発見の修理 — bin/lattice-mcp.mjs が sensor/dist/index.js を直import して
4
- * MCPServer を起動する経路には、sensor CLI (sensor/src/bin/sensor.ts) が
5
- * 持つNode versionガードが一切通っていなかった)。
6
- *
7
- * 閾値・banner文言は複製せず、sensor CLI と同じ
8
- * `sensor/dist/bin/node-version-check.js`(Node 25.x のV8 turboshaft WASM JIT
9
- * Zone allocatorバグと、MIN_NODE_MAJOR未満のfloorを扱う)から再利用する。
10
- *
11
- * 判定ロジックだけをここに切り出し、process.versions.node / env を直接
12
- * 読まない純関数にすることで、境界値(MIN_NODE_MAJOR-1 / MIN_NODE_MAJOR /
13
- * 24 / 25 / 26、override有無)をunit testで固定できるようにする。
14
- */
15
-
16
- import {
1
+ /** Shared sensor/MCP runtime compatibility policy; no bootstrap side effects. */
2
+ export {
17
3
  MIN_NODE_MAJOR,
18
- buildNode25BlockBanner,
19
- buildNodeTooOldBanner,
20
- isNode25Affected,
4
+ evaluateNodeVersionGuard,
21
5
  } from '../sensor/dist/bin/node-version-check.js';
22
-
23
- export { MIN_NODE_MAJOR };
24
-
25
- /**
26
- * @param {string} nodeVersion - process.versions.node相当の文字列 (例: "26.5.0")
27
- * @param {boolean} overrideActive - LATTICE_SENSOR_ALLOW_UNSAFE_NODE が設定されているか
28
- * @returns {{ blocked: boolean, banner: string | null }}
29
- * banner: 表示すべきbanner文言(sensor CLIと同一文言)。null ならサポート
30
- * 対象内で表示不要。
31
- * blocked: true なら呼び出し側は起動を止める(exit 1 相当)。
32
- * override有効時は nodeMajor が範囲外でも blocked は false になるが、
33
- * banner は「表示のみ」で non-null のまま返る(sensor CLIと同一意味論)。
34
- */
35
- export function evaluateNodeVersionGuard(nodeVersion, overrideActive) {
36
- const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
37
- if (isNode25Affected(nodeMajor)) {
38
- return { blocked: !overrideActive, banner: buildNode25BlockBanner(nodeVersion) };
39
- }
40
- if (nodeMajor < MIN_NODE_MAJOR) {
41
- return { blocked: !overrideActive, banner: buildNodeTooOldBanner(nodeVersion) };
42
- }
43
- return { blocked: false, banner: null };
44
- }
@@ -1,4 +1,3 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { randomBytes } from 'node:crypto';
3
2
  import { homedir } from 'node:os';
4
3
  import {
@@ -9,6 +8,10 @@ import path from 'node:path';
9
8
  import packageJson from '../package.json' with { type: 'json' };
10
9
  import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
11
10
  import { observeStartIdentityEpochMs } from './runtime-os-observation.mjs';
11
+ import { spawnSupervisedDashboard } from './dashboard-supervisor.mjs';
12
+ import { reconcileLifecycleRuns, readDashboardLifecycleStatus,
13
+ recordDashboardEnsureDecision } from './dashboard-lifecycle-journal.mjs';
14
+ import { observeDashboardProcess } from './dashboard-process-control.mjs';
12
15
 
13
16
  /**
14
17
  * The version of the code in THIS process. A daemon loads its modules once at
@@ -49,6 +52,10 @@ function paths(env) {
49
52
  };
50
53
  }
51
54
 
55
+ export async function readTodoDashboardLifecycleStatus({ env = process.env } = {}) {
56
+ return readDashboardLifecycleStatus(runtimeDir(env));
57
+ }
58
+
52
59
  function validEntry(entry) {
53
60
  return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
54
61
  && Object.keys(entry).sort().join(',') === 'display_name,last_seen_at,project_id,repo_root,session_id'
@@ -523,7 +530,7 @@ export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}
523
530
  await rm(daemonRecordRef(paths(env), process.pid), { force: true });
524
531
  }
525
532
 
526
- export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
533
+ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawnSupervisedDashboard,
527
534
  signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
528
535
  legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
529
536
  attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000,
@@ -535,15 +542,18 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
535
542
  const reap = (keepPid) => reapStrayDaemons(refs, keepPid,
536
543
  { signalProcess, isProcessAlive, timeoutMs: strayStopTimeoutMs });
537
544
  return withLock(refs.startupLock, async () => {
545
+ await reconcileLifecycleRuns(refs.root, observeDashboardProcess);
538
546
  const existing = await readJson(refs.descriptor, null);
539
547
  const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
540
548
  if (existingAttestation === 'current') {
549
+ await recordDashboardEnsureDecision(refs.root, 'reuse_current', existing.pid);
541
550
  await reap(existing.pid);
542
551
  return existing;
543
552
  }
544
553
  if (validDaemonDescriptor(existing) && existingAttestation === null
545
554
  && await isProcessAlive(existing.pid)
546
555
  && !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) {
556
+ await recordDashboardEnsureDecision(refs.root, 'alive_unresponsive', existing.pid);
547
557
  throw dashboardError('DASHBOARD_DAEMON_UNRESPONSIVE', 'dashboard daemon is alive but temporarily unresponsive');
548
558
  }
549
559
  let legacy = existingAttestation === 'legacy' ? existing : null;
@@ -558,6 +568,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
558
568
  // 引き取った相手は生きて応答しているので、descriptorの不変条件を満たす。
559
569
  await atomicJson(refs.descriptor, adopted.record);
560
570
  if (adopted.attestation === 'current') {
571
+ await recordDashboardEnsureDecision(refs.root, 'adopt_current', adopted.record.pid);
561
572
  await reap(adopted.record.pid);
562
573
  return adopted.record;
563
574
  }
@@ -569,9 +580,14 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
569
580
  && Number(portText) <= 65_535 ? Number(portText) : DEFAULT_PORT;
570
581
  const port = legacy === null ? configuredPort : 0;
571
582
  if (legacy === null) await rm(refs.descriptor, { force: true });
572
- const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
583
+ const startupDecision = legacy !== null ? 'replace_legacy'
584
+ : validDaemonDescriptor(existing) && await isProcessAlive(existing.pid) ? 'pid_reused'
585
+ : 'start_missing';
586
+ await recordDashboardEnsureDecision(refs.root, startupDecision, legacy?.pid ?? null);
587
+ const child = await spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
573
588
  detached: true,
574
589
  stdio: 'ignore',
590
+ version: TODO_DASHBOARD_CODE_VERSION,
575
591
  env: { ...env, LATTICE_DASHBOARD_RUNTIME_DIR: refs.root, LATTICE_DASHBOARD_PORT: String(port) },
576
592
  });
577
593
  child.unref();
@@ -584,6 +600,10 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
584
600
  const deadline = Date.now() + startupTimeoutMs;
585
601
  while (Date.now() < deadline) {
586
602
  await new Promise((resolve) => setTimeout(resolve, 50));
603
+ if (child.observationError) {
604
+ child.kill?.('SIGTERM');
605
+ throw child.observationError;
606
+ }
587
607
  const descriptor = typeof child.pid === 'number'
588
608
  ? await readJson(daemonRecordRef(refs, child.pid), null) : null;
589
609
  const healthy = await daemonHealthy(descriptor);
@@ -601,6 +621,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
601
621
  }
602
622
  }
603
623
  await atomicJson(refs.descriptor, descriptor);
624
+ await recordDashboardEnsureDecision(refs.root, startupDecision, descriptor.pid);
604
625
  await reap(descriptor.pid);
605
626
  return descriptor;
606
627
  }