@quolu/lattice 0.45.1 → 0.46.0

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.
@@ -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) process.exit(0);
114
- }).catch(() => process.exit(1)), 1_000);
115
- const close = async () => {
116
- clearInterval(timer);
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.45.1",
3
+ "version": "0.46.0",
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
- const deadline = Date.now() + timeoutMs;
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.root, { recursive: true, mode: 0o700 });
294
- await atomicJson(refs.descriptor, { schema: DAEMON_SCHEMA, pid: process.pid, port,
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') return existing;
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
- const legacy = existingAttestation === 'legacy' ? existing : null;
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
  }