@quolu/lattice 0.71.0 → 0.72.0

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
@@ -8,6 +8,10 @@ import packageJson from '../package.json' with { type: 'json' };
8
8
  installPipeCloseGuard();
9
9
 
10
10
  const args = process.argv.slice(2);
11
+ // 作業ディレクトリを読まないsurface。cwdが消えた席(閉じたworktree)からでも動かす。
12
+ const CWD_FREE_COMMANDS = new Set(['runtime-errors', 'bridge', 'hooks', 'setup', 'factory-diagnostics']);
13
+ // 内部故障の観測(下のobserveInternalFailure)が終わるのを、exit前に待つための置き場。
14
+ let pendingObservation = Promise.resolve();
11
15
  installSqliteExperimentalWarningFilter();
12
16
  const help = renderCliHelp(args);
13
17
 
@@ -15,6 +19,16 @@ if (help !== null) {
15
19
  process.stdout.write(help);
16
20
  } else if (args.length === 1 && args[0] === '--version') {
17
21
  process.stdout.write(`${packageJson.version}\n`);
22
+ } else if (!CWD_FREE_COMMANDS.has(args[0]) && workingDirectory() === null) {
23
+ // 呼び出し元のcwdが削除済み(閉じたworktreeに残ったshell等)。`process.cwd()`のENOENTが
24
+ // 各分岐で生のstackや内部故障(LATTICE.CLI_INTERNAL_FAILED)として漏れていた。製品の故障ではなく
25
+ // 呼び出し環境の不備なので、観測せずtyped契約で原因と直し方を返す。
26
+ process.stderr.write(`${JSON.stringify({
27
+ schema: 'lattice.cli_error.v2', code: 'CWD_UNAVAILABLE',
28
+ message: 'current working directory no longer exists',
29
+ detail: { next_action: 'cd_to_existing_project_directory_then_retry' },
30
+ })}\n`);
31
+ process.exitCode = 1;
18
32
  } else if (args.length === 2 && args[0] === 'session-context' && args[1] === '--json') {
19
33
  const { runSessionContext } = await import('../src/project-cli.mjs');
20
34
  process.exitCode = await runSessionContext({
@@ -117,7 +131,9 @@ if (help !== null) {
117
131
  cwd: process.cwd(),
118
132
  stdout: process.stdout,
119
133
  stderr: process.stderr,
134
+ onInternalFailure: (error, commandKind) => observeInternalFailure(error, commandKind),
120
135
  });
136
+ await pendingObservation;
121
137
  } else if (args[0] === 'bridge') {
122
138
  const { runBridgeCli } = await import('../src/bridge-cli.mjs');
123
139
  process.exitCode = await runBridgeCli({
@@ -142,8 +158,9 @@ if (help !== null) {
142
158
  });
143
159
  } catch (error) {
144
160
  // typed契約(cli_error.v2+exit 1/2)の外へ漏れた例外=内部故障。opt-in時のみ観測を残す。
145
- const { observeRuntimeError } = await import('../src/runtime-errors.mjs');
146
- 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;
147
164
  // **理由を捨てない。** 型名だけでは、何が起きたかを追う手段が無い——実際、seam解決の
148
165
  // `witness_set_invalid`はAPIを直接叩くまで見えなかった。typed契約の外へ漏れたこと自体は
149
166
  // 内部故障だが、漏れた中身は残す。
@@ -160,6 +177,25 @@ if (help !== null) {
160
177
  }
161
178
  }
162
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
+
190
+ function workingDirectory() {
191
+ try {
192
+ return process.cwd();
193
+ } catch (error) {
194
+ if (error?.code === 'ENOENT') return null;
195
+ throw error;
196
+ }
197
+ }
198
+
163
199
  async function runRuntimeErrorsCli(rest) {
164
200
  const runtimeErrors = await import('../src/runtime-errors.mjs');
165
201
  const usage = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
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,15 @@ 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
+
40
49
  const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
41
50
  const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
42
51
  const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
@@ -86,9 +95,35 @@ export function runtimeCollectionEnabled(env = process.env, configPath) {
86
95
  return collectionEnabled({ env, configPath });
87
96
  }
88
97
 
89
- function fingerprintOf(code) {
98
+ const validCommandKind = (value) => typeof value === 'string' && value.length <= COMMAND_KIND_MAX && COMMAND_KIND.test(value);
99
+ const validErrorKind = (value) => value === 'other' || ERROR_KINDS.has(value);
100
+ const validCauseCode = (value) => value === 'none' || (typeof value === 'string' && CAUSE_CODE.test(value));
101
+ const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KEYS)
102
+ && validCommandKind(value.command_kind) && validErrorKind(value.error_kind) && validCauseCode(value.cause_code);
103
+
104
+ /**
105
+ * 落ちた面と例外から、記録へ載せる固定語彙の分類を作る。語彙に無い値は`other`/`none`へ落とす
106
+ * ——呼び出し側が何を渡しても、利用者の入力やpathが記録へ入らない。
107
+ */
108
+ export function runtimeErrorSafeContext({ commandKind, error } = {}) {
109
+ const errorKind = error?.constructor?.name;
110
+ return {
111
+ command_kind: validCommandKind(commandKind) ? commandKind : 'other',
112
+ error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
113
+ cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
114
+ };
115
+ }
116
+
117
+ /**
118
+ * 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
119
+ * ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
120
+ * 連結順・NUL区切り・末尾区切り無しを変えない。
121
+ */
122
+ function fingerprintOf(code, safeContext = null) {
90
123
  const definition = definitions[code];
91
- return createHash('sha256').update(`${PRODUCT}\0${definition.component}\0${code}\0${definition.template}`).digest('hex');
124
+ const parts = [PRODUCT, definition.component, code, definition.template];
125
+ if (safeContext !== null) parts.push(...SAFE_CONTEXT_KEYS.map((key) => safeContext[key]));
126
+ return createHash('sha256').update(parts.join('\0')).digest('hex');
92
127
  }
93
128
 
94
129
  function assertPosix(info, mode) {
@@ -109,6 +144,8 @@ function ensureSafeFile(path) {
109
144
  assertPosix(statSync(path), 0o600);
110
145
  }
111
146
 
147
+ 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']);
148
+
112
149
  const empty = () => ({ schema: RUNTIME_ERRORS_SCHEMA, next_sequence: 1, acknowledged_through: 0, records: [] });
113
150
 
114
151
  function validate(store) {
@@ -119,11 +156,13 @@ function validate(store) {
119
156
  const seen = new Set();
120
157
  let previous = 0;
121
158
  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');
159
+ if (!plain(record) || (!exact(record, RECORD_KEYS) && !exact(record, [...RECORD_KEYS, 'safe_context']))) throw Error('state_invalid');
160
+ const safeContext = Object.hasOwn(record, 'safe_context') ? record.safe_context : null;
161
+ if (safeContext !== null && !validSafeContext(safeContext)) throw Error('state_invalid');
123
162
  const definition = definitions[record.error_code];
124
163
  if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
125
164
  || record.component !== definition.component || record.message_template !== definition.template
126
- || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code)
165
+ || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
127
166
  || seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
128
167
  || !validTime(record.first_seen) || !validTime(record.last_seen)
129
168
  || Date.parse(record.first_seen) > Date.parse(record.last_seen)
@@ -219,7 +258,7 @@ function snapshot(options = {}) {
219
258
  version: options.version ?? 'unknown',
220
259
  state_schema_version: STATE_VERSION,
221
260
  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 })),
261
+ 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
262
  resolutions: rows.filter((record) => record.status === 'resolved').map(({ fingerprint, resolved_at, reason_code }) => ({ fingerprint, resolved_at, reason_code })),
224
263
  diagnostics: {
225
264
  collection: enabled ? 'enabled' : 'disabled',
@@ -264,7 +303,10 @@ export function recordRuntimeError(code, options = {}) {
264
303
  const { path } = optionsFor(options);
265
304
  return lock(path, () => {
266
305
  const store = readStore(path);
267
- const key = fingerprintOf(code);
306
+ // 新しい発生は必ず分類つきの記録へ入る。分類を渡さない呼び出しは`other`/`none`で埋める。
307
+ const safeContext = options.safeContext === undefined ? runtimeErrorSafeContext() : options.safeContext;
308
+ if (!validSafeContext(safeContext)) throw Error('invalid_safe_context');
309
+ const key = fingerprintOf(code, safeContext);
268
310
  const sequence = store.next_sequence++;
269
311
  const time = now(options);
270
312
  const version = options.version ?? '0.0.0';
@@ -289,6 +331,7 @@ export function recordRuntimeError(code, options = {}) {
289
331
  message_template: definition.template, severity: definition.severity, fingerprint: key,
290
332
  count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
291
333
  os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
334
+ safe_context: { ...safeContext },
292
335
  });
293
336
  }
294
337
  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)}/` };