@quolu/lattice 0.71.1 → 0.72.1

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.
@@ -126,8 +126,8 @@ if (nodeVersionGuard.blocked) {
126
126
  await server.start();
127
127
  } catch (err) {
128
128
  // opt-in runtime error store(既定OFF・config有効時のみ)。観測失敗はstderr一行のbest-effort。
129
- const { observeRuntimeError } = await import('../src/runtime-errors.mjs');
130
- observeRuntimeError('LATTICE.MCP_SERVER_FAILED');
129
+ const { observeRuntimeError, runtimeErrorSafeContext } = await import('../src/runtime-errors.mjs');
130
+ observeRuntimeError('LATTICE.MCP_SERVER_FAILED', { safeContext: runtimeErrorSafeContext({ error: err }) });
131
131
  startupFailure('MCP_STARTUP_FAILED', err instanceof Error ? err.message : String(err));
132
132
  }
133
133
  }
package/bin/lattice.mjs CHANGED
@@ -10,6 +10,8 @@ installPipeCloseGuard();
10
10
  const args = process.argv.slice(2);
11
11
  // 作業ディレクトリを読まないsurface。cwdが消えた席(閉じたworktree)からでも動かす。
12
12
  const CWD_FREE_COMMANDS = new Set(['runtime-errors', 'bridge', 'hooks', 'setup', 'factory-diagnostics']);
13
+ // 内部故障の観測(下のobserveInternalFailure)が終わるのを、exit前に待つための置き場。
14
+ let pendingObservation = Promise.resolve();
13
15
  installSqliteExperimentalWarningFilter();
14
16
  const help = renderCliHelp(args);
15
17
 
@@ -129,7 +131,9 @@ if (help !== null) {
129
131
  cwd: process.cwd(),
130
132
  stdout: process.stdout,
131
133
  stderr: process.stderr,
134
+ onInternalFailure: (error, commandKind) => observeInternalFailure(error, commandKind),
132
135
  });
136
+ await pendingObservation;
133
137
  } else if (args[0] === 'bridge') {
134
138
  const { runBridgeCli } = await import('../src/bridge-cli.mjs');
135
139
  process.exitCode = await runBridgeCli({
@@ -154,8 +158,9 @@ if (help !== null) {
154
158
  });
155
159
  } catch (error) {
156
160
  // typed契約(cli_error.v2+exit 1/2)の外へ漏れた例外=内部故障。opt-in時のみ観測を残す。
157
- const { observeRuntimeError } = await import('../src/runtime-errors.mjs');
158
- observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', { version: packageJson.version });
161
+ const { cliCommandKind } = await import('../src/cli-command-kind.mjs');
162
+ observeInternalFailure(error, cliCommandKind(args));
163
+ await pendingObservation;
159
164
  // **理由を捨てない。** 型名だけでは、何が起きたかを追う手段が無い——実際、seam解決の
160
165
  // `witness_set_invalid`はAPIを直接叩くまで見えなかった。typed契約の外へ漏れたこと自体は
161
166
  // 内部故障だが、漏れた中身は残す。
@@ -172,6 +177,16 @@ if (help !== null) {
172
177
  }
173
178
  }
174
179
 
180
+ // typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
181
+ // 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
182
+ function observeInternalFailure(error, commandKind) {
183
+ pendingObservation = import('../src/runtime-errors.mjs').then(({ observeRuntimeError, runtimeErrorSafeContext }) => {
184
+ observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', {
185
+ version: packageJson.version, safeContext: runtimeErrorSafeContext({ commandKind, error }),
186
+ });
187
+ }).catch(() => {});
188
+ }
189
+
175
190
  function workingDirectory() {
176
191
  try {
177
192
  return process.cwd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.71.1",
3
+ "version": "0.72.1",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 落ちたCLIの面を、固定の語彙(`<surface>`か`<surface>.<subcommand>`)へ写す。
3
+ *
4
+ * runtime error記録の`safe_context.command_kind`に載る値で、工場reportを通ってBugHubまで運ばれる。
5
+ * 利用者が打った引数の値(run ref・path・plan key等)は載せない——この一覧に在る語だけを返し、
6
+ * 一覧に無い語は面の名前だけ、面も無ければ`other`へ落とす。
7
+ */
8
+ const RUNTIME_SURFACES = Object.freeze({
9
+ plan: Object.freeze(['compile', 'verify']),
10
+ run: Object.freeze(['abandon', 'activate', 'adapter', 'close', 'conflict', 'finding', 'hold', 'intake',
11
+ 'landing', 'list', 'observe', 'recompile', 'reprocess', 'request', 'resume', 'seam', 'start', 'status']),
12
+ event: Object.freeze(['verify']),
13
+ });
14
+
15
+ export function cliCommandKind(argv, surfaces = RUNTIME_SURFACES) {
16
+ if (!Array.isArray(argv)) return 'other';
17
+ const [surface, subcommand] = argv;
18
+ if (typeof surface !== 'string' || !Object.hasOwn(surfaces, surface)) return 'other';
19
+ return surfaces[surface].includes(subcommand) ? `${surface}.${subcommand}` : surface;
20
+ }
@@ -37,6 +37,18 @@ const definitions = Object.freeze({
37
37
  'LATTICE.MCP_SERVER_FAILED': { component: 'mcp', severity: 'high', template: 'Lattice MCP server failed' },
38
38
  });
39
39
 
40
+ // safe_context(工場wireの任意欄)。固定語彙だけを載せ、message本文・path・引数の値・stackは載せない。
41
+ // 3つのキーは必ずそろえる——fingerprintがこの3つを含むので、欠けると記録を一意に決められない。
42
+ const SAFE_CONTEXT_KEYS = Object.freeze(['command_kind', 'error_kind', 'cause_code']);
43
+ const ERROR_KINDS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError', 'AggregateError', 'SystemError']);
44
+ const COMMAND_KIND = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)?$/;
45
+ const COMMAND_KIND_MAX = 48;
46
+ // Nodeが付けるerror code(ENOENT・ERR_MODULE_NOT_FOUND等)だけを通す。Lattice自身のcodeや任意文字列は`none`。
47
+ const CAUSE_CODE = /^(?:E[A-Z0-9]{2,15}|ERR_[A-Z0-9_]{1,60})$/;
48
+ // 工場の`factory-reporter.json`が名乗る端末の種類。dotagentsの契約(lib/factory/contract.mjs)と同じ語を
49
+ // 受ける——ここに無い語の端末は設定全体が無効とみなされ、故障を1件も記録しない。
50
+ const HOST_PROFILES = Object.freeze(['server', 'mac', 'linux', 'wsl', 'windows-native']);
51
+
40
52
  const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
41
53
  const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
42
54
  const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
@@ -74,7 +86,7 @@ function collectionEnabled(options = {}) {
74
86
  return plain(config) && exact(config, ['schema_version', 'host', 'collection', 'reporting'])
75
87
  && config.schema_version === '1.0' && plain(config.host) && exact(config.host, ['id', 'profile'])
76
88
  && typeof config.host.id === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(config.host.id)
77
- && ['server', 'mac', 'wsl', 'windows-native'].includes(config.host.profile)
89
+ && HOST_PROFILES.includes(config.host.profile)
78
90
  && plain(config.collection) && exact(config.collection, ['enabled']) && config.collection.enabled === true
79
91
  && canonicalReporting(config.reporting);
80
92
  } catch {
@@ -86,9 +98,35 @@ export function runtimeCollectionEnabled(env = process.env, configPath) {
86
98
  return collectionEnabled({ env, configPath });
87
99
  }
88
100
 
89
- function fingerprintOf(code) {
101
+ const validCommandKind = (value) => typeof value === 'string' && value.length <= COMMAND_KIND_MAX && COMMAND_KIND.test(value);
102
+ const validErrorKind = (value) => value === 'other' || ERROR_KINDS.has(value);
103
+ const validCauseCode = (value) => value === 'none' || (typeof value === 'string' && CAUSE_CODE.test(value));
104
+ const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KEYS)
105
+ && validCommandKind(value.command_kind) && validErrorKind(value.error_kind) && validCauseCode(value.cause_code);
106
+
107
+ /**
108
+ * 落ちた面と例外から、記録へ載せる固定語彙の分類を作る。語彙に無い値は`other`/`none`へ落とす
109
+ * ——呼び出し側が何を渡しても、利用者の入力やpathが記録へ入らない。
110
+ */
111
+ export function runtimeErrorSafeContext({ commandKind, error } = {}) {
112
+ const errorKind = error?.constructor?.name;
113
+ return {
114
+ command_kind: validCommandKind(commandKind) ? commandKind : 'other',
115
+ error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
116
+ cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
117
+ };
118
+ }
119
+
120
+ /**
121
+ * 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
122
+ * ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
123
+ * 連結順・NUL区切り・末尾区切り無しを変えない。
124
+ */
125
+ function fingerprintOf(code, safeContext = null) {
90
126
  const definition = definitions[code];
91
- return createHash('sha256').update(`${PRODUCT}\0${definition.component}\0${code}\0${definition.template}`).digest('hex');
127
+ const parts = [PRODUCT, definition.component, code, definition.template];
128
+ if (safeContext !== null) parts.push(...SAFE_CONTEXT_KEYS.map((key) => safeContext[key]));
129
+ return createHash('sha256').update(parts.join('\0')).digest('hex');
92
130
  }
93
131
 
94
132
  function assertPosix(info, mode) {
@@ -109,6 +147,8 @@ function ensureSafeFile(path) {
109
147
  assertPosix(statSync(path), 0o600);
110
148
  }
111
149
 
150
+ const RECORD_KEYS = Object.freeze(['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence']);
151
+
112
152
  const empty = () => ({ schema: RUNTIME_ERRORS_SCHEMA, next_sequence: 1, acknowledged_through: 0, records: [] });
113
153
 
114
154
  function validate(store) {
@@ -119,11 +159,13 @@ function validate(store) {
119
159
  const seen = new Set();
120
160
  let previous = 0;
121
161
  for (const record of store.records) {
122
- if (!plain(record) || !exact(record, ['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence'])) throw Error('state_invalid');
162
+ if (!plain(record) || (!exact(record, RECORD_KEYS) && !exact(record, [...RECORD_KEYS, 'safe_context']))) throw Error('state_invalid');
163
+ const safeContext = Object.hasOwn(record, 'safe_context') ? record.safe_context : null;
164
+ if (safeContext !== null && !validSafeContext(safeContext)) throw Error('state_invalid');
123
165
  const definition = definitions[record.error_code];
124
166
  if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
125
167
  || record.component !== definition.component || record.message_template !== definition.template
126
- || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code)
168
+ || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
127
169
  || seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
128
170
  || !validTime(record.first_seen) || !validTime(record.last_seen)
129
171
  || Date.parse(record.first_seen) > Date.parse(record.last_seen)
@@ -219,7 +261,7 @@ function snapshot(options = {}) {
219
261
  version: options.version ?? 'unknown',
220
262
  state_schema_version: STATE_VERSION,
221
263
  cursor: { high_watermark: store.next_sequence - 1, acknowledged_through: store.acknowledged_through, next: rows.at(-1)?.sequence ?? afterCursor },
222
- runtime_errors: rows.filter((record) => record.status === 'open').map(({ product_version, error_code, component, status, severity, fingerprint, message_template, count, first_seen, last_seen, state_schema_version }) => ({ product_version, error_code, component, status, severity, fingerprint, message_template, occurrence_count: count, first_seen, last_seen, state_schema_version })),
264
+ runtime_errors: rows.filter((record) => record.status === 'open').map(({ product_version, error_code, component, status, severity, fingerprint, message_template, count, first_seen, last_seen, state_schema_version, safe_context }) => ({ product_version, error_code, component, status, severity, fingerprint, message_template, occurrence_count: count, first_seen, last_seen, state_schema_version, ...(safe_context === undefined ? {} : { safe_context }) })),
223
265
  resolutions: rows.filter((record) => record.status === 'resolved').map(({ fingerprint, resolved_at, reason_code }) => ({ fingerprint, resolved_at, reason_code })),
224
266
  diagnostics: {
225
267
  collection: enabled ? 'enabled' : 'disabled',
@@ -264,7 +306,10 @@ export function recordRuntimeError(code, options = {}) {
264
306
  const { path } = optionsFor(options);
265
307
  return lock(path, () => {
266
308
  const store = readStore(path);
267
- const key = fingerprintOf(code);
309
+ // 新しい発生は必ず分類つきの記録へ入る。分類を渡さない呼び出しは`other`/`none`で埋める。
310
+ const safeContext = options.safeContext === undefined ? runtimeErrorSafeContext() : options.safeContext;
311
+ if (!validSafeContext(safeContext)) throw Error('invalid_safe_context');
312
+ const key = fingerprintOf(code, safeContext);
268
313
  const sequence = store.next_sequence++;
269
314
  const time = now(options);
270
315
  const version = options.version ?? '0.0.0';
@@ -289,6 +334,7 @@ export function recordRuntimeError(code, options = {}) {
289
334
  message_template: definition.template, severity: definition.severity, fingerprint: key,
290
335
  count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
291
336
  os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
337
+ safe_context: { ...safeContext },
292
338
  });
293
339
  }
294
340
  store.records.sort((a, b) => a.sequence - b.sequence);
package/src/todo-cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { cliCommandKind } from './cli-command-kind.mjs';
1
2
  import { gitSync } from './git-process.mjs';
2
3
  import { createHash } from 'node:crypto';
3
4
  import {
@@ -3755,12 +3756,22 @@ async function repairStoreEol({ repoRoot }) {
3755
3756
  * `lattice todo` namespace. Exact position, order, and argument count are part of
3756
3757
  * the public contract; usage failures never use a JSON envelope.
3757
3758
  */
3758
- export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env }) {
3759
+ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env,
3760
+ onInternalFailure = null }) {
3759
3761
  if (!Array.isArray(argv) || typeof cwd !== 'string'
3760
3762
  || typeof stdout?.write !== 'function' || typeof stderr?.write !== 'function'
3761
- || env === null || typeof env !== 'object' || Array.isArray(env)) {
3763
+ || env === null || typeof env !== 'object' || Array.isArray(env)
3764
+ || (onInternalFailure !== null && typeof onInternalFailure !== 'function')) {
3762
3765
  throw new TypeError('runTodoCli optionsが不正');
3763
3766
  }
3767
+ // typed契約の外へ漏れた例外は、binが渡す観測口へも知らせる(opt-inのruntime error記録)。
3768
+ // 観測口を持つのはprocess境界のbinだけ——このmoduleを直接呼ぶ試験や埋め込みは記録しない。
3769
+ const failInternally = (error) => {
3770
+ try {
3771
+ onInternalFailure?.(error, cliCommandKind(['todo', ...argv], { todo: TODO_COMMAND_NAMES }));
3772
+ } catch { /* 観測の失敗でCLIの応答を変えない */ }
3773
+ return internalFailure(stderr, error);
3774
+ };
3764
3775
 
3765
3776
  const atomicCommit = argv.at(-1) === '--commit-store';
3766
3777
  if (atomicCommit) argv = argv.slice(0, -1);
@@ -3777,7 +3788,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3777
3788
  await runTodoSchemaCommand(argv[0], stdout);
3778
3789
  return 0;
3779
3790
  } catch (error) {
3780
- return internalFailure(stderr, error);
3791
+ return failInternally(error);
3781
3792
  }
3782
3793
  }
3783
3794
 
@@ -3799,7 +3810,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3799
3810
  } catch (error) {
3800
3811
  if (typeof error?.code === 'string' && error.detail !== null
3801
3812
  && typeof error.detail === 'object') return typedFailure(stderr, error);
3802
- return internalFailure(stderr, error);
3813
+ return failInternally(error);
3803
3814
  }
3804
3815
  }
3805
3816
 
@@ -4248,6 +4259,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
4248
4259
  message: error.message,
4249
4260
  });
4250
4261
  }
4251
- return internalFailure(stderr, error);
4262
+ return failInternally(error);
4252
4263
  }
4253
4264
  }
@@ -68,16 +68,12 @@ function validateRegistry(value) {
68
68
  if (value === null || typeof value !== 'object' || Array.isArray(value)
69
69
  || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
70
70
  || !value.projects.every(validEntry)) {
71
- const error = new Error('dashboard registry schema invalid');
72
- error.code = 'DASHBOARD_REGISTRY_INVALID';
73
- throw error;
71
+ throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry schema invalid', { retry: false });
74
72
  }
75
73
  const ids = new Set();
76
74
  for (const entry of value.projects) {
77
75
  if (ids.has(entry.project_id)) {
78
- const error = new Error(`dashboard project duplicate: ${entry.project_id}`);
79
- error.code = 'DASHBOARD_REGISTRY_INVALID';
80
- throw error;
76
+ throw dashboardError('DASHBOARD_REGISTRY_INVALID', `dashboard project duplicate: ${entry.project_id}`, { retry: false });
81
77
  }
82
78
  ids.add(entry.project_id);
83
79
  }
@@ -91,12 +87,25 @@ async function readJson(ref, missing) {
91
87
  throw error;
92
88
  }
93
89
  try { return JSON.parse(bytes); } catch {
94
- const error = new Error(`invalid JSON: ${ref}`);
95
- error.code = 'DASHBOARD_REGISTRY_INVALID';
96
- throw error;
90
+ // local absolute pathをCLI errorへ運ばない。どのfileかは登録簿の置き場から決まる。
91
+ throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry file is not valid JSON', { retry: false });
97
92
  }
98
93
  }
99
94
 
95
+ /**
96
+ * 登録簿とdaemonの故障は、外部状態(別process・file・負荷)の不整合であって内部故障ではない。
97
+ * codeだけのErrorはCLIのtyped契約(code+detail)に乗らず`INTERNAL_FAILURE`へ落ちていた
98
+ * ——launchdの`todo dashboard ensure`が、負荷時のlock待ち切れやdaemonの無応答をそう返していた。
99
+ * detailを必ず持たせ、再実行で解ける種類かどうかを`next_action`で伝える。
100
+ */
101
+ function dashboardError(code, message, { retry = true } = {}) {
102
+ const error = new Error(message);
103
+ error.code = code;
104
+ error.detail = { reason: code.toLowerCase(),
105
+ next_action: retry ? 'lattice todo dashboard ensure --json' : null };
106
+ return error;
107
+ }
108
+
100
109
  async function atomicJson(ref, value) {
101
110
  const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
102
111
  await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx', flush: true });
@@ -131,9 +140,7 @@ async function withLock(lockRef, action) {
131
140
  await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
132
141
  }
133
142
  }
134
- const error = new Error('dashboard registry lock timeout');
135
- error.code = 'DASHBOARD_REGISTRY_BUSY';
136
- throw error;
143
+ throw dashboardError('DASHBOARD_REGISTRY_BUSY', 'dashboard registry lock timeout');
137
144
  }
138
145
 
139
146
  export async function readActiveTodoDashboardProjects({ env = process.env, now = Date.now() } = {}) {
@@ -414,15 +421,11 @@ async function stopAttestedLegacyDaemon(descriptor, {
414
421
  const attestation = await daemonAttestation(descriptor);
415
422
  if (attestation !== 'legacy') {
416
423
  if (!await isProcessAlive(descriptor.pid) && attestation === null) return;
417
- const error = new Error('legacy dashboard daemon attestation was lost before signal');
418
- error.code = 'DASHBOARD_LEGACY_ATTESTATION_LOST';
419
- throw error;
424
+ throw dashboardError('DASHBOARD_LEGACY_ATTESTATION_LOST', 'legacy dashboard daemon attestation was lost before signal');
420
425
  }
421
426
  if (!await requestDaemonShutdown(descriptor)) signalProcess(descriptor.pid, 'SIGTERM');
422
427
  if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return;
423
- const error = new Error('legacy dashboard daemon did not stop');
424
- error.code = 'DASHBOARD_LEGACY_STOP_FAILED';
425
- throw error;
428
+ throw dashboardError('DASHBOARD_LEGACY_STOP_FAILED', 'legacy dashboard daemon did not stop');
426
429
  }
427
430
 
428
431
  /**
@@ -444,9 +447,7 @@ async function stopStrayDaemon(descriptor, {
444
447
  if (await awaitDaemonStopped(descriptor, {
445
448
  isProcessAlive, deadline: Date.now() + (timeoutMs - half),
446
449
  })) return true;
447
- const error = new Error('stray dashboard daemon did not stop');
448
- error.code = 'DASHBOARD_ORPHAN_STOP_FAILED';
449
- throw error;
450
+ throw dashboardError('DASHBOARD_ORPHAN_STOP_FAILED', 'stray dashboard daemon did not stop');
450
451
  }
451
452
 
452
453
  /**
@@ -493,9 +494,7 @@ async function stopSpawnedReplacement(child, descriptor, {
493
494
  if (await stopped()) return;
494
495
  await new Promise((resolve) => setTimeout(resolve, 50));
495
496
  }
496
- const error = new Error('replacement dashboard daemon rollback did not stop');
497
- error.code = 'DASHBOARD_REPLACEMENT_ROLLBACK_FAILED';
498
- throw error;
497
+ throw dashboardError('DASHBOARD_REPLACEMENT_ROLLBACK_FAILED', 'replacement dashboard daemon rollback did not stop');
499
498
  }
500
499
 
501
500
  function currentDaemonRecord(port) {
@@ -545,9 +544,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
545
544
  if (validDaemonDescriptor(existing) && existingAttestation === null
546
545
  && await isProcessAlive(existing.pid)
547
546
  && !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) {
548
- const error = new Error('dashboard daemon is alive but temporarily unresponsive');
549
- error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
550
- throw error;
547
+ throw dashboardError('DASHBOARD_DAEMON_UNRESPONSIVE', 'dashboard daemon is alive but temporarily unresponsive');
551
548
  }
552
549
  let legacy = existingAttestation === 'legacy' ? existing : null;
553
550
  if (legacy === null) {
@@ -610,9 +607,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
610
607
  }
611
608
  child.kill?.('SIGTERM');
612
609
  if (legacy === null) await rm(refs.descriptor, { force: true });
613
- const error = new Error('dashboard daemon did not become ready');
614
- error.code = 'DASHBOARD_DAEMON_UNAVAILABLE';
615
- throw error;
610
+ throw dashboardError('DASHBOARD_DAEMON_UNAVAILABLE', 'dashboard daemon did not become ready');
616
611
  });
617
612
  }
618
613
 
@@ -634,9 +629,7 @@ export async function ensureTodoDashboardActivity(options) {
634
629
  if (!visible) await new Promise((resolve) => setTimeout(resolve, 50));
635
630
  }
636
631
  if (!visible) {
637
- const error = new Error('registered project did not become visible');
638
- error.code = 'DASHBOARD_PROJECT_UNAVAILABLE';
639
- throw error;
632
+ throw dashboardError('DASHBOARD_PROJECT_UNAVAILABLE', 'registered project did not become visible');
640
633
  }
641
634
  return { ...registered, host: '127.0.0.1', port: daemon.port,
642
635
  url: `http://127.0.0.1:${daemon.port}/projects/${encodeURIComponent(registered.projectId)}/` };