@quolu/lattice 0.69.0 → 0.69.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.69.0",
3
+ "version": "0.69.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",
@@ -3,6 +3,8 @@ import { realpath } from 'node:fs/promises';
3
3
  import { promisify } from 'node:util';
4
4
 
5
5
  const execFileAsync = promisify(execFile);
6
+ const WINDOWS_UNIX_EPOCH_TICKS = 621_355_968_000_000_000n;
7
+ const WINDOWS_TICKS_PER_MILLISECOND = 10_000n;
6
8
 
7
9
  // OS観測はobserverのlocaleへ依存させない。psのlstart書式(LC_TIME)と非ASCII argvの
8
10
  // エスケープ有無(LC_CTYPE)はlocaleで変わり、同じprocessでも観測者ごとにidentity digestが
@@ -25,6 +27,23 @@ export async function observeStartIdentityRaw(pid) {
25
27
  return stdout.trim();
26
28
  }
27
29
 
30
+ /** OS固有のstart identityをUnix epoch millisecondsへ正規化する。raw identityの契約は変えない。 */
31
+ export function processStartIdentityEpochMs(value) {
32
+ if (typeof value !== 'string') return null;
33
+ const raw = value.trim();
34
+ const parsed = Date.parse(raw);
35
+ if (Number.isFinite(parsed)) return parsed;
36
+ if (!/^[0-9]+$/u.test(raw)) return null;
37
+ const epochMs = (BigInt(raw) - WINDOWS_UNIX_EPOCH_TICKS) / WINDOWS_TICKS_PER_MILLISECOND;
38
+ if (epochMs < BigInt(Number.MIN_SAFE_INTEGER) || epochMs > BigInt(Number.MAX_SAFE_INTEGER)) return null;
39
+ return Number(epochMs);
40
+ }
41
+
42
+ /** processのstart identityをOS差のないUnix epoch millisecondsで返す。解釈不能ならnull。 */
43
+ export async function observeStartIdentityEpochMs(pid) {
44
+ return processStartIdentityEpochMs(await observeStartIdentityRaw(pid));
45
+ }
46
+
28
47
  /** processのargv(`ps -o command=`)の生文字列を返す。 */
29
48
  export async function observeArgv(pid) {
30
49
  const { stdout } = await execFileAsync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8', env: osObservationEnvironment() });
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
 
9
9
  import packageJson from '../package.json' with { type: 'json' };
10
10
  import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
11
+ import { observeStartIdentityEpochMs } from './runtime-os-observation.mjs';
11
12
 
12
13
  /**
13
14
  * The version of the code in THIS process. A daemon loads its modules once at
@@ -313,6 +314,24 @@ function processIsAlive(pid) {
313
314
  }
314
315
  }
315
316
 
317
+ /**
318
+ * descriptorはdaemonが起動してから書かれるため、同じdaemonならOSが観測したbirthは
319
+ * descriptorのstarted_atより後にならない。後ならpidは別processへ再利用済みである。
320
+ * macOS/Linuxのlstartは秒精度だが、丸められたbirthが同じdaemonのdescriptor時刻を後回る
321
+ * ことはない。比較はstrict greaterだけにし、同じ秒のPID再利用は確定不能として拒否する。
322
+ * OS adapterがbirthを時刻として解釈できない時は不明のままにし、health遅延中daemonを
323
+ * 重複起動しない従来の拒否へ戻す。
324
+ */
325
+ async function descriptorPidWasReused(descriptor, { observeProcessStartEpochMs }) {
326
+ try {
327
+ const observedAt = await observeProcessStartEpochMs(descriptor.pid);
328
+ const recordedAt = Date.parse(descriptor.started_at);
329
+ return Number.isFinite(observedAt) && Number.isFinite(recordedAt) && observedAt > recordedAt;
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+
316
335
  function daemonRecordRef(refs, pid) {
317
336
  return path.join(refs.daemons, `${pid}.json`);
318
337
  }
@@ -508,7 +527,8 @@ export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}
508
527
  export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
509
528
  signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
510
529
  legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
511
- attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000 } = {}) {
530
+ attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000,
531
+ observeProcessStartEpochMs = observeStartIdentityEpochMs } = {}) {
512
532
  const refs = paths(env);
513
533
  await mkdir(refs.root, { recursive: true, mode: 0o700 });
514
534
  // 掃除と孤児の始末の機会はここしかない。daemonの起動は全daemonが必ず通る一点であり、
@@ -523,7 +543,8 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
523
543
  return existing;
524
544
  }
525
545
  if (validDaemonDescriptor(existing) && existingAttestation === null
526
- && await isProcessAlive(existing.pid)) {
546
+ && await isProcessAlive(existing.pid)
547
+ && !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) {
527
548
  const error = new Error('dashboard daemon is alive but temporarily unresponsive');
528
549
  error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
529
550
  throw error;