@quolu/lattice 0.45.1 → 0.46.1
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.
- package/README.ja.md +6 -0
- package/bin/lattice-dashboard.mjs +27 -8
- package/package.json +1 -1
- package/src/todo-dashboard-registry.mjs +156 -14
package/README.ja.md
CHANGED
|
@@ -295,6 +295,12 @@ dashboard daemonは起動時に読み込んだ版数をhealthで名乗り、inst
|
|
|
295
295
|
その間伸びます(実測: 8 project登録で約50秒台)。待ち時間は固定秒数ではなく、spawnした子が生きている
|
|
296
296
|
間だけ待ち、子が死ねば即座に`DASHBOARD_DAEMON_UNAVAILABLE`を返します。既定120秒の上限は、応答を
|
|
297
297
|
返さない子に対するbackstopであって正常な起動時間の見積りではありません。
|
|
298
|
+
daemonの生死はdescriptor 1枚では持ちません。daemonは自分のpidの記録を書き、起動のたびに死んだ記録を
|
|
299
|
+
掃除して、descriptorから外れた生存daemonを再認証のうえ停止します。入れ替えの途中でCLI側が落ちても、
|
|
300
|
+
旧daemonが観測されないまま生き残ることはありません。descriptorだけを失った場合は2本目を建てず、
|
|
301
|
+
配信を続けているdaemonを引き取ります。signalを送るのはその場で再認証を通った相手だけで、応答しない
|
|
302
|
+
pidへは送りません(pid再利用で無関係のprocessを止めうるためです)。規約は
|
|
303
|
+
[ADR 0157](docs/adr/0157-dashboard-daemons-are-discoverable-by-record.md)が正です。
|
|
298
304
|
状態を書き込む`start / block / unblock / done / evidence promote / reopen / revise / revise-phase / revise-set`
|
|
299
305
|
では、監査actorとして次の3環境変数をすべて設定してください。
|
|
300
306
|
|
|
@@ -7,6 +7,7 @@ import { readTodoStoreStable } from '../src/todo-store.mjs';
|
|
|
7
7
|
import { projectTodoStatus } from '../src/todo-status.mjs';
|
|
8
8
|
import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
9
9
|
import {
|
|
10
|
+
forgetTodoDashboardDaemonRecord,
|
|
10
11
|
readVisibleTodoDashboardProjects,
|
|
11
12
|
todoDashboardMemberNeedsVisibility,
|
|
12
13
|
writeTodoDashboardDaemonDescriptor,
|
|
@@ -106,15 +107,33 @@ async function synchronize() {
|
|
|
106
107
|
return active.length;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
let closing = null;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 終了経路はsignalだけではない——配信するprojectが尽きた時と同期に失敗した時も
|
|
114
|
+
* ここを通る。どの経路でも自分の登録簿記録を落とす。消し損ねても次のdaemon起動が
|
|
115
|
+
* 掃除するので、後始末の失敗で終了を止めない。
|
|
116
|
+
*/
|
|
117
|
+
function close() {
|
|
118
|
+
if (closing === null) {
|
|
119
|
+
clearInterval(timer);
|
|
120
|
+
closing = (async () => {
|
|
121
|
+
try { await forgetTodoDashboardDaemonRecord({ env }); } catch {}
|
|
122
|
+
await dashboard.close();
|
|
123
|
+
})();
|
|
124
|
+
}
|
|
125
|
+
return closing;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function exit(code) {
|
|
129
|
+
close().catch(() => {}).finally(() => process.exit(code));
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
await synchronize();
|
|
110
133
|
const dashboard = await startTodoGanttDashboardServer({ registry, port });
|
|
111
134
|
await writeTodoDashboardDaemonDescriptor({ port: dashboard.port, env });
|
|
112
135
|
const timer = setInterval(() => synchronize().then((count) => {
|
|
113
|
-
if (count === 0)
|
|
114
|
-
}).catch(() =>
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
await dashboard.close();
|
|
118
|
-
};
|
|
119
|
-
process.once('SIGINT', () => close().finally(() => process.exit(0)));
|
|
120
|
-
process.once('SIGTERM', () => close().finally(() => process.exit(0)));
|
|
136
|
+
if (count === 0) exit(0);
|
|
137
|
+
}).catch(() => exit(1)), 1_000);
|
|
138
|
+
process.once('SIGINT', () => exit(0));
|
|
139
|
+
process.once('SIGTERM', () => exit(0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.1",
|
|
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",
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import {
|
|
5
|
-
mkdir, open, readFile, realpath, rename, rm, writeFile,
|
|
5
|
+
mkdir, open, readdir, readFile, realpath, rename, rm, writeFile,
|
|
6
6
|
} from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
@@ -41,6 +41,7 @@ function paths(env) {
|
|
|
41
41
|
root,
|
|
42
42
|
registry: path.join(root, 'projects.json'),
|
|
43
43
|
descriptor: path.join(root, 'daemon.json'),
|
|
44
|
+
daemons: path.join(root, 'daemons'),
|
|
44
45
|
lock: path.join(root, 'registry.lock'),
|
|
45
46
|
startupLock: path.join(root, 'daemon-start.lock'),
|
|
46
47
|
};
|
|
@@ -239,6 +240,71 @@ function processIsAlive(pid) {
|
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
function daemonRecordRef(refs, pid) {
|
|
244
|
+
return path.join(refs.daemons, `${pid}.json`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* daemonごとの記録。descriptorは1枚しか無いので、そこから落ちたdaemonは二度と誰にも
|
|
249
|
+
* 観測されず、生きたまま不死になる(2026-08-03に2重起動を実測)。descriptorの所有者は
|
|
250
|
+
* 起動側のままにして、pidごとの記録だけをdaemon自身に書かせる——記録があれば、
|
|
251
|
+
* descriptorを失っても後続の起動が見つけられる。
|
|
252
|
+
*/
|
|
253
|
+
async function readDaemonRecords(refs) {
|
|
254
|
+
let names;
|
|
255
|
+
try { names = await readdir(refs.daemons); } catch (error) {
|
|
256
|
+
if (error?.code === 'ENOENT') return [];
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
const records = [];
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
if (!/^[1-9][0-9]{0,9}\.json$/u.test(name)) continue;
|
|
262
|
+
const ref = path.join(refs.daemons, name);
|
|
263
|
+
let record = null;
|
|
264
|
+
try { record = JSON.parse(await readFile(ref, 'utf8')); } catch { record = null; }
|
|
265
|
+
// 壊れた記録、file名のpidと中身が食い違う記録は、誰の生死も語れないので捨てる。
|
|
266
|
+
// 記録はatomicJsonで置かれるので、読み手が書きかけを見ることはない。
|
|
267
|
+
if (!validDaemonDescriptor(record) || `${record.pid}.json` !== name) {
|
|
268
|
+
await rm(ref, { force: true });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
records.push(record);
|
|
272
|
+
}
|
|
273
|
+
return records;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 死んだpidの記録を落とす。全daemonが必ず通る起動時に呼ぶので、登録簿は
|
|
278
|
+
* 「生きているdaemon+前回の起動以降に死んだ分」までしか育たない。
|
|
279
|
+
* keepはhealth応答で生存を確認済みのdaemon——process.kill(0)より強い証拠を
|
|
280
|
+
* 既に持っているので、その記録は生死判定に掛けない。
|
|
281
|
+
*/
|
|
282
|
+
async function sweepDaemonRecords(refs, { isProcessAlive, keep = null }) {
|
|
283
|
+
const live = [];
|
|
284
|
+
for (const record of await readDaemonRecords(refs)) {
|
|
285
|
+
if (record.pid === keep || await isProcessAlive(record.pid)) live.push(record);
|
|
286
|
+
else await rm(daemonRecordRef(refs, record.pid), { force: true });
|
|
287
|
+
}
|
|
288
|
+
return live;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function awaitDaemonStopped(descriptor, { isProcessAlive, deadline }) {
|
|
292
|
+
for (;;) {
|
|
293
|
+
const alive = await isProcessAlive(descriptor.pid);
|
|
294
|
+
const stillServing = await daemonAttestation(descriptor) !== null;
|
|
295
|
+
if (!alive && !stillServing) return true;
|
|
296
|
+
if (Date.now() >= deadline) return false;
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 相手が既に死んでいるのは停止の成功であって失敗ではない。 */
|
|
302
|
+
function signalIfPresent(signalProcess, pid, signal) {
|
|
303
|
+
try { signalProcess(pid, signal); } catch (error) {
|
|
304
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
242
308
|
async function stopAttestedLegacyDaemon(descriptor, {
|
|
243
309
|
signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
|
|
244
310
|
} = {}) {
|
|
@@ -250,18 +316,60 @@ async function stopAttestedLegacyDaemon(descriptor, {
|
|
|
250
316
|
throw error;
|
|
251
317
|
}
|
|
252
318
|
signalProcess(descriptor.pid, 'SIGTERM');
|
|
253
|
-
|
|
254
|
-
while (Date.now() < deadline) {
|
|
255
|
-
const alive = await isProcessAlive(descriptor.pid);
|
|
256
|
-
const stillServing = await daemonAttestation(descriptor) !== null;
|
|
257
|
-
if (!alive && !stillServing) return;
|
|
258
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
259
|
-
}
|
|
319
|
+
if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return;
|
|
260
320
|
const error = new Error('legacy dashboard daemon did not stop');
|
|
261
321
|
error.code = 'DASHBOARD_LEGACY_STOP_FAILED';
|
|
262
322
|
throw error;
|
|
263
323
|
}
|
|
264
324
|
|
|
325
|
+
/**
|
|
326
|
+
* descriptorの外に居るdaemonを止める。signalを送るのは、いま再認証を通った相手だけ——
|
|
327
|
+
* 応答しないpidへ送れば、pid再利用で無関係のprocessを殺しうる。認証できない生存pidは
|
|
328
|
+
* 記録を残したまま見送り、次の起動が判定し直す。
|
|
329
|
+
* 認証を通った相手はLattice自身のdaemonなので、SIGTERMで死ななければSIGKILLへ上げる。
|
|
330
|
+
* 置換前のlegacy停止と1つの関数へ畳まない——あちらは認証を失ったこと自体がerror
|
|
331
|
+
* (版を入れ替える確証が要る)で、こちらは見送ってよい。契約が違う。
|
|
332
|
+
*/
|
|
333
|
+
async function stopStrayDaemon(descriptor, {
|
|
334
|
+
signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
|
|
335
|
+
} = {}) {
|
|
336
|
+
if (await daemonAttestation(descriptor) === null) return false;
|
|
337
|
+
signalIfPresent(signalProcess, descriptor.pid, 'SIGTERM');
|
|
338
|
+
const half = Math.ceil(timeoutMs / 2);
|
|
339
|
+
if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + half })) return true;
|
|
340
|
+
signalIfPresent(signalProcess, descriptor.pid, 'SIGKILL');
|
|
341
|
+
if (await awaitDaemonStopped(descriptor, {
|
|
342
|
+
isProcessAlive, deadline: Date.now() + (timeoutMs - half),
|
|
343
|
+
})) return true;
|
|
344
|
+
const error = new Error('stray dashboard daemon did not stop');
|
|
345
|
+
error.code = 'DASHBOARD_ORPHAN_STOP_FAILED';
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* descriptorが指すdaemon以外を掃除する。呼ぶのはdescriptorの整合が確立した後だけ——
|
|
351
|
+
* 失敗経路で呼べば、可用性を守るrollback契約より先に生きたdaemonを消してしまう。
|
|
352
|
+
*/
|
|
353
|
+
async function reapStrayDaemons(refs, keepPid, { signalProcess, isProcessAlive, timeoutMs }) {
|
|
354
|
+
for (const record of await sweepDaemonRecords(refs, { isProcessAlive, keep: keepPid })) {
|
|
355
|
+
if (record.pid === keepPid) continue;
|
|
356
|
+
if (await stopStrayDaemon(record, { signalProcess, isProcessAlive, timeoutMs })) {
|
|
357
|
+
await rm(daemonRecordRef(refs, record.pid), { force: true });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** descriptorを失った時、登録簿の生存daemonから配信を続けている1つを選ぶ。 */
|
|
363
|
+
async function adoptableDaemon(records, { attestationTimeoutMs }) {
|
|
364
|
+
let legacy = null;
|
|
365
|
+
for (const record of records) {
|
|
366
|
+
const attestation = await daemonAttestation(record, { timeoutMs: attestationTimeoutMs });
|
|
367
|
+
if (attestation === 'current') return { record, attestation };
|
|
368
|
+
if (attestation === 'legacy' && legacy === null) legacy = { record, attestation };
|
|
369
|
+
}
|
|
370
|
+
return legacy;
|
|
371
|
+
}
|
|
372
|
+
|
|
265
373
|
async function stopSpawnedReplacement(child, descriptor, {
|
|
266
374
|
isProcessAlive = processIsAlive, timeoutMs = 3_000,
|
|
267
375
|
} = {}) {
|
|
@@ -290,28 +398,61 @@ async function stopSpawnedReplacement(child, descriptor, {
|
|
|
290
398
|
export async function writeTodoDashboardDaemonDescriptor({ port, env = process.env }) {
|
|
291
399
|
if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) throw new TypeError('daemon port invalid');
|
|
292
400
|
const refs = paths(env);
|
|
293
|
-
await mkdir(refs.
|
|
294
|
-
|
|
295
|
-
started_at: new Date().toISOString() }
|
|
401
|
+
await mkdir(refs.daemons, { recursive: true, mode: 0o700 });
|
|
402
|
+
const record = { schema: DAEMON_SCHEMA, pid: process.pid, port,
|
|
403
|
+
started_at: new Date().toISOString() };
|
|
404
|
+
// 記録を先に置く。descriptorだけが在って記録が無い瞬間を作ると、その隙に起動側が
|
|
405
|
+
// 死んだ時に、まさに直そうとしている「誰からも観測されないdaemon」が生まれる。
|
|
406
|
+
await atomicJson(daemonRecordRef(refs, process.pid), record);
|
|
407
|
+
await atomicJson(refs.descriptor, record);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 自分の記録だけを消す。descriptorは起動側が所有するので、死ぬ側からは触らない。 */
|
|
411
|
+
export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}) {
|
|
412
|
+
await rm(daemonRecordRef(paths(env), process.pid), { force: true });
|
|
296
413
|
}
|
|
297
414
|
|
|
298
415
|
export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
|
|
299
416
|
signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
|
|
300
417
|
legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
|
|
301
|
-
attestationTimeoutMs = 2_000 } = {}) {
|
|
418
|
+
attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000 } = {}) {
|
|
302
419
|
const refs = paths(env);
|
|
303
420
|
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
421
|
+
// 掃除と孤児の始末の機会はここしかない。daemonの起動は全daemonが必ず通る一点であり、
|
|
422
|
+
// 誰かがコマンドを叩くのを待つ形にすると、記録は誰にも掃除されず積み上がる。
|
|
423
|
+
const reap = (keepPid) => reapStrayDaemons(refs, keepPid,
|
|
424
|
+
{ signalProcess, isProcessAlive, timeoutMs: strayStopTimeoutMs });
|
|
304
425
|
return withLock(refs.startupLock, async () => {
|
|
305
426
|
const existing = await readJson(refs.descriptor, null);
|
|
306
427
|
const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
|
|
307
|
-
if (existingAttestation === 'current')
|
|
428
|
+
if (existingAttestation === 'current') {
|
|
429
|
+
await reap(existing.pid);
|
|
430
|
+
return existing;
|
|
431
|
+
}
|
|
308
432
|
if (validDaemonDescriptor(existing) && existingAttestation === null
|
|
309
433
|
&& await isProcessAlive(existing.pid)) {
|
|
310
434
|
const error = new Error('dashboard daemon is alive but temporarily unresponsive');
|
|
311
435
|
error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
|
|
312
436
|
throw error;
|
|
313
437
|
}
|
|
314
|
-
|
|
438
|
+
let legacy = existingAttestation === 'legacy' ? existing : null;
|
|
439
|
+
if (legacy === null) {
|
|
440
|
+
// descriptorの指す先が居ない。登録簿に配信中のdaemonが残っているなら、2本目を
|
|
441
|
+
// 建てずに引き取る——descriptorだけが失われた場合の直接の修理である。
|
|
442
|
+
const adopted = await adoptableDaemon(
|
|
443
|
+
(await sweepDaemonRecords(refs, { isProcessAlive })).filter(({ pid }) => pid !== existing?.pid),
|
|
444
|
+
{ attestationTimeoutMs },
|
|
445
|
+
);
|
|
446
|
+
if (adopted !== null) {
|
|
447
|
+
// 引き取った相手は生きて応答しているので、descriptorの不変条件を満たす。
|
|
448
|
+
await atomicJson(refs.descriptor, adopted.record);
|
|
449
|
+
if (adopted.attestation === 'current') {
|
|
450
|
+
await reap(adopted.record.pid);
|
|
451
|
+
return adopted.record;
|
|
452
|
+
}
|
|
453
|
+
legacy = adopted.record;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
315
456
|
const portText = env.LATTICE_DASHBOARD_PORT;
|
|
316
457
|
const configuredPort = typeof portText === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(portText)
|
|
317
458
|
&& Number(portText) <= 65_535 ? Number(portText) : DEFAULT_PORT;
|
|
@@ -348,6 +489,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
|
|
|
348
489
|
throw error;
|
|
349
490
|
}
|
|
350
491
|
}
|
|
492
|
+
await reap(descriptor.pid);
|
|
351
493
|
return descriptor;
|
|
352
494
|
}
|
|
353
495
|
}
|