diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f3b2c22..a5385647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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/package-lock.json b/package-lock.json index 894b7bd1..c723ffa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.71.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.71.2", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@clack/prompts": "^1.3.0", diff --git a/package.json b/package.json index 9f865b3d..b761edc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@quolu/lattice", - "version": "0.71.1", + "version": "0.71.2", "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/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/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']); +});