Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

### 修正
Expand Down
4 changes: 2 additions & 2 deletions bin/lattice-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
19 changes: 17 additions & 2 deletions bin/lattice.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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({
Expand All @@ -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契約の外へ漏れたこと自体は
// 内部故障だが、漏れた中身は残す。
Expand All @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions docs/01_integration-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 編入の前提条件(残余リスク恒久化・回帰条件)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions src/cli-command-kind.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* 落ちたCLIの面を、固定の語彙(`<surface>`か`<surface>.<subcommand>`)へ写す。
*
* 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;
}
55 changes: 49 additions & 6 deletions src/runtime-errors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Expand Down
21 changes: 16 additions & 5 deletions src/todo-cli.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cliCommandKind } from './cli-command-kind.mjs';
import { gitSync } from './git-process.mjs';
import { createHash } from 'node:crypto';
import {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}
Loading
Loading