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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

### 修正
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.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",
Expand Down
59 changes: 26 additions & 33 deletions src/todo-dashboard-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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 });
Expand Down Expand Up @@ -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() } = {}) {
Expand Down Expand Up @@ -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');
}

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

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
});
}

Expand All @@ -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)}/` };
Expand Down
59 changes: 59 additions & 0 deletions test/todo-dashboard-typed-errors.test.mjs
Original file line number Diff line number Diff line change
@@ -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']);
});
Loading