diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3b2c22..b9c441d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.72.0 — 2026-10-03 + +### runtime error記録: 落ちた面と例外の分類(`safe_context`) + +- BugHubの `LATTICE.CLI_INTERNAL_FAILED`(mac-kite、26回)は、どのcommandで落ちたかが記録に無く、 + 原因を確定できないまま閉じた。新しい記録は固定語彙の分類 `safe_context` を持つ。 + - `command_kind`: 落ちたCLIの面(`run.list`・`todo.start` 等、Latticeが持つ一覧の語だけ。無ければ `other`)。 + - `error_kind`: 例外の種類(`Error`・`TypeError`・`RangeError`・`SyntaxError`・`ReferenceError`・ + `AggregateError`・`SystemError`、それ以外は `other`)。 + - `cause_code`: Nodeが付けるerror code(`ENOENT`・`ERR_MODULE_NOT_FOUND` 等。無ければ `none`)。 + - 3つは必ずそろえる。message本文・path・引数の値・stackは載せない。 +- fingerprintは、分類つきの記録では + `sha256(product \0 component \0 error_code \0 message_template \0 command_kind \0 error_kind \0 cause_code)`。 + 原因が違えば別の記録(BugHubでは別のissue)になる。分類を持たない旧記録は前の式のまま残り、 + 新しい発生は旧記録へ加算されない。 +- `todo` のcommandがtyped契約の外で落ちた時(`INTERNAL_FAILURE`)も、run系と同じ + `LATTICE.CLI_INTERNAL_FAILED` として記録する。これまでは記録されなかった。 +- 受け側の前提: dotagents `898c6e1` 以降(`safe_context` を受け入れ、同じ式でfingerprintを照合する)と、 + BugHubの lattice 許可キー(`command_kind`・`error_kind`・`cause_code`)。 + +## 0.71.2 — 2026-10-03 + +### 修正 + +- `todo dashboard ensure` が、登録簿のlock待ち切れ(`DASHBOARD_REGISTRY_BUSY`)やdaemonの無応答 + (`DASHBOARD_DAEMON_UNRESPONSIVE`)などを `INTERNAL_FAILURE` とstackの抜粋で返していた。 + 登録簿とdaemonの故障はcodeと `detail`(`reason`・`next_action`)を持つtyped errorとして返す。 + 再実行で解ける種類は `next_action` に `lattice todo dashboard ensure --json` を示す。 +- 登録簿のJSONが壊れている時のmessageから、localの絶対pathを外した。 + ## 0.71.1 — 2026-10-03 ### 修正 diff --git a/bin/lattice-mcp.mjs b/bin/lattice-mcp.mjs index b91c989a..7752152e 100755 --- a/bin/lattice-mcp.mjs +++ b/bin/lattice-mcp.mjs @@ -126,8 +126,8 @@ if (nodeVersionGuard.blocked) { await server.start(); } catch (err) { // opt-in runtime error store(既定OFF・config有効時のみ)。観測失敗はstderr一行のbest-effort。 - const { observeRuntimeError } = await import('../src/runtime-errors.mjs'); - observeRuntimeError('LATTICE.MCP_SERVER_FAILED'); + const { observeRuntimeError, runtimeErrorSafeContext } = await import('../src/runtime-errors.mjs'); + observeRuntimeError('LATTICE.MCP_SERVER_FAILED', { safeContext: runtimeErrorSafeContext({ error: err }) }); startupFailure('MCP_STARTUP_FAILED', err instanceof Error ? err.message : String(err)); } } diff --git a/bin/lattice.mjs b/bin/lattice.mjs index 2fb835d0..8ed69f37 100755 --- a/bin/lattice.mjs +++ b/bin/lattice.mjs @@ -10,6 +10,8 @@ installPipeCloseGuard(); const args = process.argv.slice(2); // 作業ディレクトリを読まないsurface。cwdが消えた席(閉じたworktree)からでも動かす。 const CWD_FREE_COMMANDS = new Set(['runtime-errors', 'bridge', 'hooks', 'setup', 'factory-diagnostics']); +// 内部故障の観測(下のobserveInternalFailure)が終わるのを、exit前に待つための置き場。 +let pendingObservation = Promise.resolve(); installSqliteExperimentalWarningFilter(); const help = renderCliHelp(args); @@ -129,7 +131,9 @@ if (help !== null) { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, + onInternalFailure: (error, commandKind) => observeInternalFailure(error, commandKind), }); + await pendingObservation; } else if (args[0] === 'bridge') { const { runBridgeCli } = await import('../src/bridge-cli.mjs'); process.exitCode = await runBridgeCli({ @@ -154,8 +158,9 @@ if (help !== null) { }); } catch (error) { // typed契約(cli_error.v2+exit 1/2)の外へ漏れた例外=内部故障。opt-in時のみ観測を残す。 - const { observeRuntimeError } = await import('../src/runtime-errors.mjs'); - observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', { version: packageJson.version }); + const { cliCommandKind } = await import('../src/cli-command-kind.mjs'); + observeInternalFailure(error, cliCommandKind(args)); + await pendingObservation; // **理由を捨てない。** 型名だけでは、何が起きたかを追う手段が無い——実際、seam解決の // `witness_set_invalid`はAPIを直接叩くまで見えなかった。typed契約の外へ漏れたこと自体は // 内部故障だが、漏れた中身は残す。 @@ -172,6 +177,16 @@ if (help !== null) { } } +// typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で +// 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。 +function observeInternalFailure(error, commandKind) { + pendingObservation = import('../src/runtime-errors.mjs').then(({ observeRuntimeError, runtimeErrorSafeContext }) => { + observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', { + version: packageJson.version, safeContext: runtimeErrorSafeContext({ commandKind, error }), + }); + }).catch(() => {}); +} + function workingDirectory() { try { return process.cwd(); diff --git a/docs/01_integration-package.md b/docs/01_integration-package.md index 6ba04e56..c9a45c57 100644 --- a/docs/01_integration-package.md +++ b/docs/01_integration-package.md @@ -225,6 +225,14 @@ native Windowsでは`HOST_PLATFORM_UNSUPPORTED`を返し、設定やstateへ書 reporting(BugHub送信)はdotagents adapter所有で本storeは外部送信しない(collection/reporting分離)。 固定catalog 5 code・fingerprint集約・cursor/ack・resolved+ack済み30日compact・POSIX owner-only検査で fail closed。正典は`src/runtime-errors.mjs` + - 各記録は任意の`safe_context`を持つ: `command_kind`(落ちたCLIの面。`run.list`・`todo.start`等、 + Latticeが持つ一覧の語だけ。無ければ`other`)、`error_kind`(例外の種類。一覧に無ければ`other`)、 + `cause_code`(Nodeが付けるerror code。無ければ`none`)。付ける時は3つを必ずそろえる。 + message本文・path・引数の値・stackは載せない。 + - fingerprintは、`safe_context`を持つ記録では + `sha256(product \0 component \0 error_code \0 message_template \0 command_kind \0 error_kind \0 cause_code)`、 + 持たない旧記録では前の4要素だけ。dotagentsのadapterが同じ式で再計算して照合する。 + - typed契約の外へ漏れた例外(`INTERNAL_FAILURE`)は、run系と`todo`の両方で`LATTICE.CLI_INTERNAL_FAILED`として記録する。 ## 6. 編入の前提条件(残余リスク恒久化・回帰条件) diff --git a/package-lock.json b/package-lock.json index 894b7bd1..af219584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.72.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.72.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 9f865b3d..3b66c34f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.72.0", "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution", "author": { "name": "Quo / クオ at kitepon.dev", diff --git a/src/cli-command-kind.mjs b/src/cli-command-kind.mjs new file mode 100644 index 00000000..a723a79a --- /dev/null +++ b/src/cli-command-kind.mjs @@ -0,0 +1,20 @@ +/** + * 落ちたCLIの面を、固定の語彙(``か`.`)へ写す。 + * + * runtime error記録の`safe_context.command_kind`に載る値で、工場reportを通ってBugHubまで運ばれる。 + * 利用者が打った引数の値(run ref・path・plan key等)は載せない——この一覧に在る語だけを返し、 + * 一覧に無い語は面の名前だけ、面も無ければ`other`へ落とす。 + */ +const RUNTIME_SURFACES = Object.freeze({ + plan: Object.freeze(['compile', 'verify']), + run: Object.freeze(['abandon', 'activate', 'adapter', 'close', 'conflict', 'finding', 'hold', 'intake', + 'landing', 'list', 'observe', 'recompile', 'reprocess', 'request', 'resume', 'seam', 'start', 'status']), + event: Object.freeze(['verify']), +}); + +export function cliCommandKind(argv, surfaces = RUNTIME_SURFACES) { + if (!Array.isArray(argv)) return 'other'; + const [surface, subcommand] = argv; + if (typeof surface !== 'string' || !Object.hasOwn(surfaces, surface)) return 'other'; + return surfaces[surface].includes(subcommand) ? `${surface}.${subcommand}` : surface; +} diff --git a/src/runtime-errors.mjs b/src/runtime-errors.mjs index 3adc8b65..627244b6 100644 --- a/src/runtime-errors.mjs +++ b/src/runtime-errors.mjs @@ -37,6 +37,15 @@ const definitions = Object.freeze({ 'LATTICE.MCP_SERVER_FAILED': { component: 'mcp', severity: 'high', template: 'Lattice MCP server failed' }, }); +// safe_context(工場wireの任意欄)。固定語彙だけを載せ、message本文・path・引数の値・stackは載せない。 +// 3つのキーは必ずそろえる——fingerprintがこの3つを含むので、欠けると記録を一意に決められない。 +const SAFE_CONTEXT_KEYS = Object.freeze(['command_kind', 'error_kind', 'cause_code']); +const ERROR_KINDS = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError', 'AggregateError', 'SystemError']); +const COMMAND_KIND = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)?$/; +const COMMAND_KIND_MAX = 48; +// Nodeが付けるerror code(ENOENT・ERR_MODULE_NOT_FOUND等)だけを通す。Lattice自身のcodeや任意文字列は`none`。 +const CAUSE_CODE = /^(?:E[A-Z0-9]{2,15}|ERR_[A-Z0-9_]{1,60})$/; + const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); 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) { return collectionEnabled({ env, configPath }); } -function fingerprintOf(code) { +const validCommandKind = (value) => typeof value === 'string' && value.length <= COMMAND_KIND_MAX && COMMAND_KIND.test(value); +const validErrorKind = (value) => value === 'other' || ERROR_KINDS.has(value); +const validCauseCode = (value) => value === 'none' || (typeof value === 'string' && CAUSE_CODE.test(value)); +const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KEYS) + && validCommandKind(value.command_kind) && validErrorKind(value.error_kind) && validCauseCode(value.cause_code); + +/** + * 落ちた面と例外から、記録へ載せる固定語彙の分類を作る。語彙に無い値は`other`/`none`へ落とす + * ——呼び出し側が何を渡しても、利用者の入力やpathが記録へ入らない。 + */ +export function runtimeErrorSafeContext({ commandKind, error } = {}) { + const errorKind = error?.constructor?.name; + return { + command_kind: validCommandKind(commandKind) ? commandKind : 'other', + error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other', + cause_code: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none', + }; +} + +/** + * 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める + * ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、 + * 連結順・NUL区切り・末尾区切り無しを変えない。 + */ +function fingerprintOf(code, safeContext = null) { const definition = definitions[code]; - return createHash('sha256').update(`${PRODUCT}\0${definition.component}\0${code}\0${definition.template}`).digest('hex'); + const parts = [PRODUCT, definition.component, code, definition.template]; + if (safeContext !== null) parts.push(...SAFE_CONTEXT_KEYS.map((key) => safeContext[key])); + return createHash('sha256').update(parts.join('\0')).digest('hex'); } function assertPosix(info, mode) { @@ -109,6 +144,8 @@ function ensureSafeFile(path) { assertPosix(statSync(path), 0o600); } +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']); + const empty = () => ({ schema: RUNTIME_ERRORS_SCHEMA, next_sequence: 1, acknowledged_through: 0, records: [] }); function validate(store) { @@ -119,11 +156,13 @@ function validate(store) { const seen = new Set(); let previous = 0; for (const record of store.records) { - 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'); + if (!plain(record) || (!exact(record, RECORD_KEYS) && !exact(record, [...RECORD_KEYS, 'safe_context']))) throw Error('state_invalid'); + const safeContext = Object.hasOwn(record, 'safe_context') ? record.safe_context : null; + if (safeContext !== null && !validSafeContext(safeContext)) throw Error('state_invalid'); const definition = definitions[record.error_code]; if (!definition || record.product !== PRODUCT || !validVersion(record.product_version) || record.component !== definition.component || record.message_template !== definition.template - || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code) + || record.severity !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext) || seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1 || !validTime(record.first_seen) || !validTime(record.last_seen) || Date.parse(record.first_seen) > Date.parse(record.last_seen) @@ -219,7 +258,7 @@ function snapshot(options = {}) { version: options.version ?? 'unknown', state_schema_version: STATE_VERSION, cursor: { high_watermark: store.next_sequence - 1, acknowledged_through: store.acknowledged_through, next: rows.at(-1)?.sequence ?? afterCursor }, - 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 })), + 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 }) })), resolutions: rows.filter((record) => record.status === 'resolved').map(({ fingerprint, resolved_at, reason_code }) => ({ fingerprint, resolved_at, reason_code })), diagnostics: { collection: enabled ? 'enabled' : 'disabled', @@ -264,7 +303,10 @@ export function recordRuntimeError(code, options = {}) { const { path } = optionsFor(options); return lock(path, () => { const store = readStore(path); - const key = fingerprintOf(code); + // 新しい発生は必ず分類つきの記録へ入る。分類を渡さない呼び出しは`other`/`none`で埋める。 + const safeContext = options.safeContext === undefined ? runtimeErrorSafeContext() : options.safeContext; + if (!validSafeContext(safeContext)) throw Error('invalid_safe_context'); + const key = fingerprintOf(code, safeContext); const sequence = store.next_sequence++; const time = now(options); const version = options.version ?? '0.0.0'; @@ -289,6 +331,7 @@ export function recordRuntimeError(code, options = {}) { message_template: definition.template, severity: definition.severity, fingerprint: key, count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION, os, arch, status: 'open', resolved_at: null, reason_code: null, sequence, + safe_context: { ...safeContext }, }); } store.records.sort((a, b) => a.sequence - b.sequence); diff --git a/src/todo-cli.mjs b/src/todo-cli.mjs index 134fafc2..9a7c1203 100644 --- a/src/todo-cli.mjs +++ b/src/todo-cli.mjs @@ -1,3 +1,4 @@ +import { cliCommandKind } from './cli-command-kind.mjs'; import { gitSync } from './git-process.mjs'; import { createHash } from 'node:crypto'; import { @@ -3755,12 +3756,22 @@ async function repairStoreEol({ repoRoot }) { * `lattice todo` namespace. Exact position, order, and argument count are part of * the public contract; usage failures never use a JSON envelope. */ -export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env }) { +export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env, + onInternalFailure = null }) { if (!Array.isArray(argv) || typeof cwd !== 'string' || typeof stdout?.write !== 'function' || typeof stderr?.write !== 'function' - || env === null || typeof env !== 'object' || Array.isArray(env)) { + || env === null || typeof env !== 'object' || Array.isArray(env) + || (onInternalFailure !== null && typeof onInternalFailure !== 'function')) { throw new TypeError('runTodoCli optionsが不正'); } + // typed契約の外へ漏れた例外は、binが渡す観測口へも知らせる(opt-inのruntime error記録)。 + // 観測口を持つのはprocess境界のbinだけ——このmoduleを直接呼ぶ試験や埋め込みは記録しない。 + const failInternally = (error) => { + try { + onInternalFailure?.(error, cliCommandKind(['todo', ...argv], { todo: TODO_COMMAND_NAMES })); + } catch { /* 観測の失敗でCLIの応答を変えない */ } + return internalFailure(stderr, error); + }; const atomicCommit = argv.at(-1) === '--commit-store'; if (atomicCommit) argv = argv.slice(0, -1); @@ -3777,7 +3788,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env await runTodoSchemaCommand(argv[0], stdout); return 0; } catch (error) { - return internalFailure(stderr, error); + return failInternally(error); } } @@ -3799,7 +3810,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env } catch (error) { if (typeof error?.code === 'string' && error.detail !== null && typeof error.detail === 'object') return typedFailure(stderr, error); - return internalFailure(stderr, error); + return failInternally(error); } } @@ -4248,6 +4259,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env message: error.message, }); } - return internalFailure(stderr, error); + return failInternally(error); } } diff --git a/src/todo-dashboard-registry.mjs b/src/todo-dashboard-registry.mjs index 767d2f4f..bc7b3ae5 100644 --- a/src/todo-dashboard-registry.mjs +++ b/src/todo-dashboard-registry.mjs @@ -68,16 +68,12 @@ function validateRegistry(value) { if (value === null || typeof value !== 'object' || Array.isArray(value) || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects) || !value.projects.every(validEntry)) { - const error = new Error('dashboard registry schema invalid'); - error.code = 'DASHBOARD_REGISTRY_INVALID'; - throw error; + throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry schema invalid', { retry: false }); } const ids = new Set(); for (const entry of value.projects) { if (ids.has(entry.project_id)) { - const error = new Error(`dashboard project duplicate: ${entry.project_id}`); - error.code = 'DASHBOARD_REGISTRY_INVALID'; - throw error; + throw dashboardError('DASHBOARD_REGISTRY_INVALID', `dashboard project duplicate: ${entry.project_id}`, { retry: false }); } ids.add(entry.project_id); } @@ -91,12 +87,25 @@ async function readJson(ref, missing) { throw error; } try { return JSON.parse(bytes); } catch { - const error = new Error(`invalid JSON: ${ref}`); - error.code = 'DASHBOARD_REGISTRY_INVALID'; - throw error; + // local absolute pathをCLI errorへ運ばない。どのfileかは登録簿の置き場から決まる。 + throw dashboardError('DASHBOARD_REGISTRY_INVALID', 'dashboard registry file is not valid JSON', { retry: false }); } } +/** + * 登録簿とdaemonの故障は、外部状態(別process・file・負荷)の不整合であって内部故障ではない。 + * codeだけのErrorはCLIのtyped契約(code+detail)に乗らず`INTERNAL_FAILURE`へ落ちていた + * ——launchdの`todo dashboard ensure`が、負荷時のlock待ち切れやdaemonの無応答をそう返していた。 + * detailを必ず持たせ、再実行で解ける種類かどうかを`next_action`で伝える。 + */ +function dashboardError(code, message, { retry = true } = {}) { + const error = new Error(message); + error.code = code; + error.detail = { reason: code.toLowerCase(), + next_action: retry ? 'lattice todo dashboard ensure --json' : null }; + return error; +} + async function atomicJson(ref, value) { const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`; await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx', flush: true }); @@ -131,9 +140,7 @@ async function withLock(lockRef, action) { await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS)); } } - const error = new Error('dashboard registry lock timeout'); - error.code = 'DASHBOARD_REGISTRY_BUSY'; - throw error; + throw dashboardError('DASHBOARD_REGISTRY_BUSY', 'dashboard registry lock timeout'); } export async function readActiveTodoDashboardProjects({ env = process.env, now = Date.now() } = {}) { @@ -414,15 +421,11 @@ async function stopAttestedLegacyDaemon(descriptor, { const attestation = await daemonAttestation(descriptor); if (attestation !== 'legacy') { if (!await isProcessAlive(descriptor.pid) && attestation === null) return; - const error = new Error('legacy dashboard daemon attestation was lost before signal'); - error.code = 'DASHBOARD_LEGACY_ATTESTATION_LOST'; - throw error; + throw dashboardError('DASHBOARD_LEGACY_ATTESTATION_LOST', 'legacy dashboard daemon attestation was lost before signal'); } if (!await requestDaemonShutdown(descriptor)) signalProcess(descriptor.pid, 'SIGTERM'); if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return; - const error = new Error('legacy dashboard daemon did not stop'); - error.code = 'DASHBOARD_LEGACY_STOP_FAILED'; - throw error; + throw dashboardError('DASHBOARD_LEGACY_STOP_FAILED', 'legacy dashboard daemon did not stop'); } /** @@ -444,9 +447,7 @@ async function stopStrayDaemon(descriptor, { if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + (timeoutMs - half), })) return true; - const error = new Error('stray dashboard daemon did not stop'); - error.code = 'DASHBOARD_ORPHAN_STOP_FAILED'; - throw error; + throw dashboardError('DASHBOARD_ORPHAN_STOP_FAILED', 'stray dashboard daemon did not stop'); } /** @@ -493,9 +494,7 @@ async function stopSpawnedReplacement(child, descriptor, { if (await stopped()) return; await new Promise((resolve) => setTimeout(resolve, 50)); } - const error = new Error('replacement dashboard daemon rollback did not stop'); - error.code = 'DASHBOARD_REPLACEMENT_ROLLBACK_FAILED'; - throw error; + throw dashboardError('DASHBOARD_REPLACEMENT_ROLLBACK_FAILED', 'replacement dashboard daemon rollback did not stop'); } function currentDaemonRecord(port) { @@ -545,9 +544,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon if (validDaemonDescriptor(existing) && existingAttestation === null && await isProcessAlive(existing.pid) && !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) { - const error = new Error('dashboard daemon is alive but temporarily unresponsive'); - error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE'; - throw error; + throw dashboardError('DASHBOARD_DAEMON_UNRESPONSIVE', 'dashboard daemon is alive but temporarily unresponsive'); } let legacy = existingAttestation === 'legacy' ? existing : null; if (legacy === null) { @@ -610,9 +607,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon } child.kill?.('SIGTERM'); if (legacy === null) await rm(refs.descriptor, { force: true }); - const error = new Error('dashboard daemon did not become ready'); - error.code = 'DASHBOARD_DAEMON_UNAVAILABLE'; - throw error; + throw dashboardError('DASHBOARD_DAEMON_UNAVAILABLE', 'dashboard daemon did not become ready'); }); } @@ -634,9 +629,7 @@ export async function ensureTodoDashboardActivity(options) { if (!visible) await new Promise((resolve) => setTimeout(resolve, 50)); } if (!visible) { - const error = new Error('registered project did not become visible'); - error.code = 'DASHBOARD_PROJECT_UNAVAILABLE'; - throw error; + throw dashboardError('DASHBOARD_PROJECT_UNAVAILABLE', 'registered project did not become visible'); } return { ...registered, host: '127.0.0.1', port: daemon.port, url: `http://127.0.0.1:${daemon.port}/projects/${encodeURIComponent(registered.projectId)}/` }; diff --git a/test/runtime-error-safe-context.test.mjs b/test/runtime-error-safe-context.test.mjs new file mode 100644 index 00000000..625f3efa --- /dev/null +++ b/test/runtime-error-safe-context.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import nodeTest from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { cliCommandKind } from '../src/cli-command-kind.mjs'; +import { + recordRuntimeError, + runtimeErrorSafeContext, + runtimeErrorsSnapshot, +} from '../src/runtime-errors.mjs'; + +// runtime error storeはPOSIX専用(Windows nativeはstore_unsafeでfail closed)。 +const test = process.platform === 'win32' ? nodeTest.skip : nodeTest; + +const cliPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'lattice.mjs'); +const VALID_CONFIG = { + schema_version: '1.0', + host: { id: 'test-host', profile: 'mac' }, + collection: { enabled: true }, + reporting: { enabled: false }, +}; +const CLI_FAILED = 'LATTICE.CLI_INTERNAL_FAILED'; +const CLI_PARTS = ['lattice', 'cli', CLI_FAILED, 'Lattice CLI crashed outside the typed error contract']; +// dotagentsのadapterが再計算して照合する式。実装を呼ばず、契約の文面どおりにここで組む。 +const sha256 = (parts) => createHash('sha256').update(parts.join('\0')).digest('hex'); + +async function makeWorkspace() { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-context-')); + const configPath = path.join(root, 'config', 'factory-reporter.json'); + const storePath = path.join(root, 'state', 'runtime-errors.json'); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify(VALID_CONFIG)); + return { root, storePath, options: { configPath, storePath, version: '0.72.0' } }; +} + +test('分類は固定語彙だけを通し、語彙に無い値はother/noneへ落とす', () => { + assert.deepEqual(runtimeErrorSafeContext(), { command_kind: 'other', error_kind: 'other', cause_code: 'none' }); + assert.deepEqual( + runtimeErrorSafeContext({ commandKind: 'run.list', + error: Object.assign(new Error('ENOENT: /Users/someone/secret'), { code: 'ENOENT' }) }), + { command_kind: 'run.list', error_kind: 'Error', cause_code: 'ENOENT' }, + ); + assert.equal(runtimeErrorSafeContext({ error: new TypeError('x') }).error_kind, 'TypeError'); + assert.equal(runtimeErrorSafeContext({ + error: Object.assign(new Error('x'), { code: 'ERR_MODULE_NOT_FOUND' }) }).cause_code, 'ERR_MODULE_NOT_FOUND'); + class PrivateDetailError extends Error {} + for (const [input, expected] of [ + [{ commandKind: '/Users/someone/repo' }, { command_kind: 'other' }], + [{ commandKind: 'run.list.extra' }, { command_kind: 'other' }], + [{ commandKind: `run.${'a'.repeat(60)}` }, { command_kind: 'other' }], + [{ error: new PrivateDetailError('x') }, { error_kind: 'other' }], + [{ error: Object.assign(new Error('x'), { code: 'DASHBOARD_REGISTRY_BUSY' }) }, { cause_code: 'none' }], + [{ error: Object.assign(new Error('x'), { code: 'user@example.com' }) }, { cause_code: 'none' }], + [{ error: Object.assign(new Error('x'), { code: 42 }) }, { cause_code: 'none' }], + ]) { + const context = runtimeErrorSafeContext(input); + for (const [key, value] of Object.entries(expected)) assert.equal(context[key], value, JSON.stringify(input)); + } +}); + +test('落ちた面は一覧に在る語だけで表し、引数の値を載せない', () => { + assert.equal(cliCommandKind(['run', 'list', '--json']), 'run.list'); + assert.equal(cliCommandKind(['run', 'activate', '--run', '.lattice/runs/secret-name']), 'run.activate'); + assert.equal(cliCommandKind(['plan', 'compile', '--request', '/abs/path.json']), 'plan.compile'); + assert.equal(cliCommandKind(['event', 'verify', '--run', 'x']), 'event.verify'); + assert.equal(cliCommandKind(['run', 'not-a-command']), 'run'); + assert.equal(cliCommandKind(['run']), 'run'); + assert.equal(cliCommandKind(['/abs/path']), 'other'); + assert.equal(cliCommandKind([]), 'other'); + assert.equal(cliCommandKind(['todo', 'start', 'T1'], { todo: ['start'] }), 'todo.start'); +}); + +test('分類つきの記録は7要素の式でfingerprintを決め、原因が違えば別の記録になる', async () => { + const workspace = await makeWorkspace(); + try { + const listContext = { command_kind: 'run.list', error_kind: 'Error', cause_code: 'ENOENT' }; + const startContext = { command_kind: 'todo.start', error_kind: 'TypeError', cause_code: 'none' }; + const first = recordRuntimeError(CLI_FAILED, { ...workspace.options, safeContext: listContext }); + recordRuntimeError(CLI_FAILED, { ...workspace.options, safeContext: listContext }); + const second = recordRuntimeError(CLI_FAILED, { ...workspace.options, safeContext: startContext }); + // 分類を渡さない呼び出しも、3つのキーをそろえて埋める。 + const bare = recordRuntimeError(CLI_FAILED, workspace.options); + + assert.equal(first.fingerprint, sha256([...CLI_PARTS, 'run.list', 'Error', 'ENOENT'])); + assert.equal(second.fingerprint, sha256([...CLI_PARTS, 'todo.start', 'TypeError', 'none'])); + assert.equal(bare.fingerprint, sha256([...CLI_PARTS, 'other', 'other', 'none'])); + + const snapshot = runtimeErrorsSnapshot(0, 256, workspace.options); + assert.deepEqual(snapshot.runtime_errors.map((record) => [record.occurrence_count, record.safe_context]), [ + [2, listContext], + [1, startContext], + [1, { command_kind: 'other', error_kind: 'other', cause_code: 'none' }], + ]); + for (const record of snapshot.runtime_errors) { + assert.deepEqual(Object.keys(record.safe_context), ['command_kind', 'error_kind', 'cause_code']); + } + + // 語彙の外・キーの欠けは記録しない(fingerprintを一意に決められない)。 + for (const broken of [ + { command_kind: 'run.list', error_kind: 'Error' }, + { ...listContext, extra: 'x' }, + { ...listContext, command_kind: '/abs/path' }, + { ...listContext, error_kind: 'PrivateDetailError' }, + { ...listContext, cause_code: 'DASHBOARD_REGISTRY_BUSY' }, + ]) { + assert.throws(() => recordRuntimeError(CLI_FAILED, { ...workspace.options, safeContext: broken }), + /invalid_safe_context/, JSON.stringify(broken)); + } + } finally { + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +test('旧記録(分類無し)は旧い式のまま読み書きでき、新しい発生は別の記録へ入る', async () => { + const workspace = await makeWorkspace(); + try { + const legacyFingerprint = sha256(CLI_PARTS); + await mkdir(path.dirname(workspace.storePath), { recursive: true, mode: 0o700 }); + await writeFile(workspace.storePath, `${JSON.stringify({ + schema: 'lattice.runtime_errors.v1', next_sequence: 2, acknowledged_through: 0, + records: [{ + product: 'lattice', product_version: '0.71.0', component: 'cli', error_code: CLI_FAILED, + message_template: 'Lattice CLI crashed outside the typed error contract', severity: 'high', + fingerprint: legacyFingerprint, count: 26, + first_seen: '2026-07-28T04:05:14.770Z', last_seen: '2026-10-03T02:44:16.086Z', + state_schema_version: '1.0', os: 'darwin', arch: 'arm64', status: 'open', + resolved_at: null, reason_code: null, sequence: 1, + }], + })}\n`, { mode: 0o600 }); + + const context = { command_kind: 'run.list', error_kind: 'Error', cause_code: 'ENOENT' }; + recordRuntimeError(CLI_FAILED, { ...workspace.options, safeContext: context }); + + const snapshot = runtimeErrorsSnapshot(0, 256, workspace.options); + assert.equal(snapshot.runtime_errors.length, 2); + const [legacy, fresh] = snapshot.runtime_errors; + assert.equal(legacy.fingerprint, legacyFingerprint); + assert.equal(legacy.occurrence_count, 26); + assert.equal(Object.hasOwn(legacy, 'safe_context'), false); + assert.equal(fresh.fingerprint, sha256([...CLI_PARTS, 'run.list', 'Error', 'ENOENT'])); + assert.deepEqual(fresh.safe_context, context); + + const stored = JSON.parse(await readFile(workspace.storePath, 'utf8')); + assert.equal(Object.hasOwn(stored.records[0], 'safe_context'), false); + assert.deepEqual(stored.records[1].safe_context, context); + } finally { + await rm(workspace.root, { recursive: true, force: true }); + } +}); + +test('CLIがtyped契約の外で落ちると、面と例外の分類つきで記録する', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'lattice-rterr-context-cli-')); + try { + const configDir = path.join(root, 'xdg-config', 'dotagents'); + await mkdir(configDir, { recursive: true, mode: 0o700 }); + await writeFile(path.join(configDir, 'factory-reporter.json'), JSON.stringify(VALID_CONFIG), { mode: 0o600 }); + const env = { ...process.env, NO_COLOR: '1', HOME: root, LATTICE_DASHBOARD_AUTOSTART: '0', + XDG_CONFIG_HOME: path.join(root, 'xdg-config'), XDG_STATE_HOME: path.join(root, 'xdg-state') }; + // `.lattice`がdirectoryでなくfileのrepo。todo storeの読取がENOTDIRで落ち、typed分岐に乗らない。 + // (この入力がtyped errorへ直されたら、別の「契約外で落ちる入力」へ差し替える。) + const repo = path.join(root, 'repo'); + await mkdir(repo); + assert.equal(spawnSync('git', ['init', '--quiet'], { cwd: repo }).status, 0); + await writeFile(path.join(repo, '.lattice'), 'not a directory\n'); + + const failed = spawnSync(process.execPath, [cliPath, 'todo', 'status', '--json'], + { cwd: repo, encoding: 'utf8', env }); + assert.equal(failed.status, 1, failed.stderr); + assert.equal(JSON.parse(failed.stderr.trim().split('\n').at(-1)).code, 'INTERNAL_FAILURE'); + + const snapshot = spawnSync(process.execPath, [cliPath, 'runtime-errors', 'snapshot', '--json'], + { cwd: repo, encoding: 'utf8', env }); + assert.equal(snapshot.status, 0, snapshot.stderr); + const { runtime_errors: records } = JSON.parse(snapshot.stdout.trim()); + assert.equal(records.length, 1); + assert.equal(records[0].error_code, CLI_FAILED); + assert.deepEqual(records[0].safe_context, + { command_kind: 'todo.status', error_kind: 'Error', cause_code: 'ENOTDIR' }); + assert.equal(records[0].fingerprint, sha256([...CLI_PARTS, 'todo.status', 'Error', 'ENOTDIR'])); + // 記録のどこにも、repoの場所や例外の本文が入らない。 + assert.equal(snapshot.stdout.includes(root), false); + assert.equal(snapshot.stdout.includes('not a directory'), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/todo-dashboard-typed-errors.test.mjs b/test/todo-dashboard-typed-errors.test.mjs new file mode 100644 index 00000000..c3dd48a5 --- /dev/null +++ b/test/todo-dashboard-typed-errors.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { ensureTodoDashboardDaemon } from '../src/todo-dashboard-registry.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cliPath = path.join(repoRoot, 'bin', 'lattice.mjs'); + +// 生きているprocess(この試験自身)が握ったままの新しいlock。待つ側は待ち切れで終わる。 +async function holdLocks(runtime) { + await mkdir(runtime, { recursive: true, mode: 0o700 }); + const held = `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`; + for (const name of ['registry.lock', 'daemon-start.lock']) { + await writeFile(path.join(runtime, name), held, { mode: 0o600 }); + } +} + +test('登録簿とdaemonの故障はcodeとdetailを持つ(CLIのtyped契約に乗る)', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'lattice-dashboard-typed-')); + t.after(() => rm(root, { recursive: true, force: true })); + const runtime = path.join(root, 'runtime'); + await holdLocks(runtime); + await assert.rejects( + ensureTodoDashboardDaemon({ env: { LATTICE_DASHBOARD_RUNTIME_DIR: runtime }, + spawnDaemon() { throw new Error('must not spawn'); } }), + (error) => { + assert.equal(error.code, 'DASHBOARD_REGISTRY_BUSY'); + assert.deepEqual(error.detail, { reason: 'dashboard_registry_busy', + next_action: 'lattice todo dashboard ensure --json' }); + return true; + }, + ); +}); + +test('todo dashboard ensureはlockの待ち切れをINTERNAL_FAILUREでなくDASHBOARD_REGISTRY_BUSYで返す', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'lattice-dashboard-typed-cli-')); + t.after(() => rm(root, { recursive: true, force: true })); + const runtime = path.join(root, 'runtime'); + await holdLocks(runtime); + // このrepository自身のtodo storeを読む(読むだけ)。lockを取れないのでdaemonは起動しない。 + const result = spawnSync(process.execPath, [cliPath, 'todo', 'dashboard', 'ensure', '--json'], { + cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', LATTICE_DASHBOARD_RUNTIME_DIR: runtime, + LATTICE_TODO_ACTOR_HOST: 'fixture-host', LATTICE_TODO_ACTOR_SESSION: 'fixture-session', + LATTICE_TODO_ACTOR_AGENT: 'fixture-agent' }, + }); + assert.equal(result.status, 1, result.stderr); + const error = JSON.parse(result.stderr.trim().split('\n').at(-1)); + assert.equal(error.schema, 'lattice.cli_error.v2'); + assert.equal(error.code, 'DASHBOARD_REGISTRY_BUSY'); + assert.equal(error.detail.next_action, 'lattice todo dashboard ensure --json'); + assert.equal(Object.hasOwn(error.detail, 'stack_excerpt'), false); + assert.deepEqual((await readdir(runtime)).sort(), ['daemon-start.lock', 'registry.lock']); +});