@quolu/lattice 0.63.1 → 0.63.3

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.63.1",
3
+ "version": "0.63.3",
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",
@@ -319,6 +319,15 @@ async function replaceCanonical({ pathname, expectedBytes, value, validator, cra
319
319
  }
320
320
  }
321
321
 
322
+ /**
323
+ * control-events.jsonを書く他module(gate storeのatomic commit)が、このstoreのappendと
324
+ * 同じper-directory直列化を共有するための入口。同一process内で書き手が2つの規律に
325
+ * 分かれると、appendがcommitの読みと置換の間へ挟まり偽のcommitted prefix衝突になる。
326
+ */
327
+ export function withControlJournalMutation(runDir, mutation) {
328
+ return enqueue(path.resolve(runDir), mutation);
329
+ }
330
+
322
331
  function enqueue(runDir, mutation) {
323
332
  const previous = mutationQueues.get(runDir) ?? Promise.resolve();
324
333
  const result = previous.then(mutation);
@@ -4,6 +4,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  import { canonicalizeArtifact } from './artifact-contracts.mjs';
7
+ import { withControlJournalMutation } from './runtime-control-store.mjs';
7
8
  import { selfDigest } from './runtime-contracts.mjs';
8
9
 
9
10
  const SHA256 = /^[0-9a-f]{64}$/;
@@ -407,9 +408,17 @@ async function commitBuilt({ runDir, built, crashInjector, controlEventsPath = n
407
408
  }
408
409
 
409
410
  export async function commitRuntimeGateActivation(options) {
410
- const built = await buildCommit(options);
411
- return commitBuilt({ runDir: options.runDir, built, crashInjector: options.crashInjector,
412
- controlEventsPath: options.controlEventsPath ?? null });
411
+ // control journalの読み(buildCommit)と置換(commitBuilt)の間へ、同一process内の
412
+ // control store appendが挟まると、committed prefix検査が偽のGATE_COMMIT_CONFLICTに
413
+ // なる(2026-08-22 CI負荷下で実被弾: managed recompileのepoch activationへdaemonの
414
+ // 並行appendが割り込んだ)。appendと同じper-directory直列化を共有して排除する。
415
+ // 別process間の競合検査は従来どおりprefix比較が持つ。
416
+ const eventsPath = options.controlEventsPath ?? path.join(options.runDir, 'control-events.json');
417
+ return withControlJournalMutation(path.dirname(eventsPath), async () => {
418
+ const built = await buildCommit(options);
419
+ return commitBuilt({ runDir: options.runDir, built, crashInjector: options.crashInjector,
420
+ controlEventsPath: options.controlEventsPath ?? null });
421
+ });
413
422
  }
414
423
 
415
424
  export async function recoverRuntimeGateCommit(options) {
@@ -1011,8 +1011,33 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
1011
1011
  fail('ADAPTER_LAUNCH_INVALID', 'existing endpoint ownerが一意でない');
1012
1012
  }
1013
1013
  }
1014
- const handshake = await exchangeControllerHandshake({ socketPath: handshakeConnectPath, runId,
1015
- supervisorSessionNonce, controllerSocketRef, timeoutMs });
1014
+ // socket fileの出現はbindの証拠でありlistenの証拠ではない。負荷下ではcontrollerが
1015
+ // bindとlistenの間でpreemptされ、fileを見て接続した一回だけECONNREFUSEDになる
1016
+ // (2026-08-22 CI負荷下で実被弾)。起動したchildに限り、listen受理までを起動待ちに
1017
+ // 含めてdeadline内はECONNREFUSEDだけを待ち直す。死んだcontrollerはexitCode検査が、
1018
+ // 起動しないcontrollerはdeadlineが従来どおり落とす。既存endpoint(child無し)は
1019
+ // owner検証済みのlistener前提なので単発のまま。
1020
+ const handshakeDeadline = Date.now() + timeoutMs;
1021
+ let handshake;
1022
+ for (;;) {
1023
+ try {
1024
+ handshake = await exchangeControllerHandshake({ socketPath: handshakeConnectPath, runId,
1025
+ supervisorSessionNonce, controllerSocketRef, timeoutMs });
1026
+ break;
1027
+ } catch (error) {
1028
+ const listenPending = child !== null && error instanceof ManagedRuntimeError
1029
+ && ['ECONNREFUSED', 'ENOENT'].some((code) => String(error.message).includes(code))
1030
+ && Date.now() < handshakeDeadline;
1031
+ if (!listenPending) throw error;
1032
+ if (child.exitCode !== null) {
1033
+ fail(
1034
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
1035
+ `controller exited: ${child.exitCode}${childStderr ? `: ${childStderr.trim()}` : ''}`,
1036
+ );
1037
+ }
1038
+ await new Promise((resolve) => setTimeout(resolve, 20));
1039
+ }
1040
+ }
1016
1041
  const controllerDescriptor = handshake.descriptor;
1017
1042
  if (controllerDescriptor.adapter_kind !== adapterKind || controllerDescriptor.socket_ref !== controllerSocketRef
1018
1043
  || controllerDescriptor.capabilities.capabilities_digest !== launch.capabilities_digest
@@ -490,18 +490,11 @@ export async function activateEpochOneStore({
490
490
  }
491
491
  }
492
492
 
493
- const meta = {
494
- schema: 'lattice.run_meta.v2',
495
- run_id: normalizedMeta.run_id,
496
- executor_adapter: normalizedMeta.executor_adapter,
497
- run_event_schema: 'lattice.run_event.v1',
498
- control_event_schema: 'lattice.runtime_control_event.v1',
499
- epoch_bundle_schema: 'lattice.runtime_epoch_bundle.v1',
500
- created_plan_digest: compileArtifact.plan.plan_digest,
501
- };
502
- meta.meta_digest = digestArtifact(meta);
503
- await replaceDurableJson(runDir, 'run-meta.json', meta);
504
-
493
+ // pointerをmetaより先に書く。読み手はmeta v2を見てからpointerを読むので、逆順だと
494
+ // 2書き込みの間にmeta v2だけが見え、並行するreaderがINVALID_RUN_STORE
495
+ // (committed epoch pointerを読めない)で落ちる(2026-08-22 CI負荷下で実被弾)。
496
+ // この順ならmeta v2の可視がpointer実在を含意し、途中crashはmeta v1のまま
497
+ // (余剰のpointerはlegacy読取りに影響せず、再activationが置換する)。
505
498
  const pointer = {
506
499
  schema: 'lattice.committed_epoch_pointer.v1',
507
500
  run_id: normalizedMeta.run_id,
@@ -513,6 +506,18 @@ export async function activateEpochOneStore({
513
506
  };
514
507
  pointer.pointer_digest = digestArtifact(pointer);
515
508
  await replaceDurableJson(runDir, 'committed-epoch.json', pointer);
509
+
510
+ const meta = {
511
+ schema: 'lattice.run_meta.v2',
512
+ run_id: normalizedMeta.run_id,
513
+ executor_adapter: normalizedMeta.executor_adapter,
514
+ run_event_schema: 'lattice.run_event.v1',
515
+ control_event_schema: 'lattice.runtime_control_event.v1',
516
+ epoch_bundle_schema: 'lattice.runtime_epoch_bundle.v1',
517
+ created_plan_digest: compileArtifact.plan.plan_digest,
518
+ };
519
+ meta.meta_digest = digestArtifact(meta);
520
+ await replaceDurableJson(runDir, 'run-meta.json', meta);
516
521
  return { bundle, meta, pointer };
517
522
  }
518
523
 
@@ -486,6 +486,11 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
486
486
  const doneDigest = new Map();
487
487
  const completion = new Map();
488
488
  const authoredStartBindings = new Map();
489
+ // hard証拠検証は即時でなくreplay完了後、まだ現在状態を支えている記述子だけへ行う。
490
+ // reopen・再doneで差し替えられた古いdoneが指すblobは一度もcommitされないままGCで消え得るし、
491
+ // 履歴の完全性はevent_digest鎖が既に持つ。即時検証だと消えたblobを指す過去eventが
492
+ // storeを恒久的に書き込み不能へ落とす(2026-08-22実被弾: reopen済み旧doneのdangling blob)。
493
+ const pendingVerifications = new Map();
489
494
  const importedGenesis = events[0]?.payload.historical_import === true;
490
495
  let previousTime = null;
491
496
  for (const event of events) {
@@ -528,13 +533,15 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
528
533
  const state = states.get(migration.to_task_id);
529
534
  Object.assign(state, structuredClone(migration.state), { evidence_unverified: false });
530
535
  if (state.evidence !== null) {
536
+ const evidence = state.evidence;
537
+ const context = { plan_key: plan.plan_key, task_id: migration.to_task_id };
531
538
  if (state.imported) {
532
- if (verifyImportSource) verifyImportSource(state.evidence, {
533
- plan_key: plan.plan_key, task_id: migration.to_task_id,
534
- });
535
- } else if (verifyEvidence) verifyEvidence(state.evidence, {
536
- plan_key: plan.plan_key, task_id: migration.to_task_id,
537
- });
539
+ if (verifyImportSource) {
540
+ pendingVerifications.set(`task:${migration.to_task_id}`, () => verifyImportSource(evidence, context));
541
+ }
542
+ } else if (verifyEvidence) {
543
+ pendingVerifications.set(`task:${migration.to_task_id}`, () => verifyEvidence(evidence, context));
544
+ }
538
545
  }
539
546
  if (state.status === 'done') {
540
547
  doneDigest.set(migration.to_task_id, event.event_digest);
@@ -571,6 +578,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
571
578
  if (currentStatus !== 'gate_ready') fail('STORE_INCONSISTENT', 'phase_gate_not_ready');
572
579
  state.status = 'reviewing'; state.review_event_digest = event.event_digest;
573
580
  state.decision_event_digest = null; state.decision_evidence = null;
581
+ pendingVerifications.delete(`phase:${event.phase_id}`);
574
582
  } else if (event.kind === 'phase_accept') {
575
583
  const phase = phasesOf(plan).find(({ phase_id }) => phase_id === event.phase_id);
576
584
  const slots = event.payload.evidence_slots.map(({ slot_id }) => slot_id);
@@ -579,8 +587,12 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
579
587
  fail('STORE_INCONSISTENT', 'phase_accept_binding_invalid');
580
588
  }
581
589
  if (verifyEvidence) {
582
- verifyEvidence(event.payload.decision_evidence);
583
- for (const slot of event.payload.evidence_slots) verifyEvidence(slot.evidence);
590
+ const decisionEvidence = event.payload.decision_evidence;
591
+ const slotEvidence = event.payload.evidence_slots.map((slot) => slot.evidence);
592
+ pendingVerifications.set(`phase:${event.phase_id}`, () => {
593
+ verifyEvidence(decisionEvidence);
594
+ for (const evidence of slotEvidence) verifyEvidence(evidence);
595
+ });
584
596
  }
585
597
  state.status = 'accepted'; state.decision_event_digest = event.event_digest;
586
598
  state.decision_evidence = event.payload.decision_evidence;
@@ -588,7 +600,10 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
588
600
  if (currentStatus !== 'reviewing' || state.review_event_digest !== event.payload.review_event_digest) {
589
601
  fail('STORE_INCONSISTENT', 'phase_reject_binding_invalid');
590
602
  }
591
- if (verifyEvidence) verifyEvidence(event.payload.decision_evidence);
603
+ if (verifyEvidence) {
604
+ const decisionEvidence = event.payload.decision_evidence;
605
+ pendingVerifications.set(`phase:${event.phase_id}`, () => verifyEvidence(decisionEvidence));
606
+ }
592
607
  state.status = 'rejected'; state.decision_event_digest = event.event_digest;
593
608
  state.decision_evidence = event.payload.decision_evidence;
594
609
  } else if (event.kind === 'phase_close_unaudited') {
@@ -597,6 +612,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
597
612
  if (currentStatus !== 'gate_ready') fail('STORE_INCONSISTENT', 'phase_gate_not_ready');
598
613
  state.status = 'closed_unaudited'; state.decision_event_digest = event.event_digest;
599
614
  state.decision_evidence = null;
615
+ pendingVerifications.delete(`phase:${event.phase_id}`);
600
616
  } else {
601
617
  // phase_reopen。ADR 0148裁定5: closed_unauditedもaccepted/rejectedと同じく
602
618
  // reopenで初期状態へ戻せる——監査せずに閉じた工程を、後から本当に監査したくなった時に
@@ -624,6 +640,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
624
640
  fail('STORE_INCONSISTENT', 'phase_reopen_has_started_successor');
625
641
  }
626
642
  Object.assign(state, emptyPhaseState(event.phase_id));
643
+ pendingVerifications.delete(`phase:${event.phase_id}`);
627
644
  }
628
645
  continue;
629
646
  }
@@ -635,9 +652,12 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
635
652
  if (!importedGenesis || state.status !== 'pending') {
636
653
  fail('STORE_INCONSISTENT', 'invalid_historical_import_start_transition');
637
654
  }
638
- if (verifyImportSource) verifyImportSource(event.payload.evidence, {
639
- plan_key: plan.plan_key, task_id: event.task_id,
640
- });
655
+ if (verifyImportSource) {
656
+ const evidence = event.payload.evidence;
657
+ pendingVerifications.set(`task:${event.task_id}`, () => verifyImportSource(evidence, {
658
+ plan_key: plan.plan_key, task_id: event.task_id,
659
+ }));
660
+ }
641
661
  state.status = 'in-progress';
642
662
  state.started_at = event.payload.started_at === 'unknown_requires_evidence'
643
663
  ? null : event.payload.started_at;
@@ -686,18 +706,24 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
686
706
  } else if (event.kind === 'done') {
687
707
  if (event.payload.done_mode === 'authored') {
688
708
  if (state.status !== 'in-progress' || !dependenciesDone) fail('STORE_INCONSISTENT', 'invalid_done_transition');
689
- if (verifyEvidence) verifyEvidence(event.payload.evidence, {
690
- plan_key: plan.plan_key, task_id: event.task_id,
691
- });
709
+ if (verifyEvidence) {
710
+ const evidence = event.payload.evidence;
711
+ pendingVerifications.set(`task:${event.task_id}`, () => verifyEvidence(evidence, {
712
+ plan_key: plan.plan_key, task_id: event.task_id,
713
+ }));
714
+ }
692
715
  state.status = 'done'; state.done_at = event.recorded_at; state.evidence = event.payload.evidence;
693
716
  state.test_result = event.payload.test_result ?? null;
694
717
  state.imported = false;
695
718
  completion.set(event.task_id, { mode: 'authored', completed_at: event.recorded_at });
696
719
  } else if (event.payload.done_mode === 'historical_import') {
697
720
  if (!importedGenesis || state.status !== 'pending') fail('STORE_INCONSISTENT', 'invalid_historical_import_transition');
698
- if (verifyImportSource) verifyImportSource(event.payload.evidence, {
699
- plan_key: plan.plan_key, task_id: event.task_id,
700
- });
721
+ if (verifyImportSource) {
722
+ const evidence = event.payload.evidence;
723
+ pendingVerifications.set(`task:${event.task_id}`, () => verifyImportSource(evidence, {
724
+ plan_key: plan.plan_key, task_id: event.task_id,
725
+ }));
726
+ }
701
727
  state.status = 'done'; state.done_at = event.payload.completed_at === 'unknown_requires_evidence'
702
728
  ? null : event.payload.completed_at;
703
729
  state.evidence = event.payload.evidence; state.imported = true;
@@ -709,9 +735,12 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
709
735
  || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
710
736
  fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
711
737
  }
712
- if (verifyEvidence) verifyEvidence(event.payload.evidence, {
713
- plan_key: plan.plan_key, task_id: event.task_id,
714
- });
738
+ if (verifyEvidence) {
739
+ const evidence = event.payload.evidence;
740
+ pendingVerifications.set(`task:${event.task_id}`, () => verifyEvidence(evidence, {
741
+ plan_key: plan.plan_key, task_id: event.task_id,
742
+ }));
743
+ }
715
744
  state.evidence = event.payload.evidence;
716
745
  completion.set(event.task_id, { mode: 'evidence_promotion', completed_at: current.completed_at });
717
746
  }
@@ -739,8 +768,10 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
739
768
  if (startedSuccessor && event.payload.override_reason === null) fail('STORE_INCONSISTENT', 'reopen_has_started_successor');
740
769
  state.status = 'in-progress'; state.done_at = null; state.evidence = null; state.test_result = null;
741
770
  completion.delete(event.task_id);
771
+ pendingVerifications.delete(`task:${event.task_id}`);
742
772
  }
743
773
  }
774
+ for (const verify of pendingVerifications.values()) verify();
744
775
  return [...states.values()].sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
745
776
  }
746
777