@quolu/lattice 0.30.1 → 0.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.30.1",
3
+ "version": "0.31.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",
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
19
19
 
20
20
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
21
21
  import { collectSensorEvidence } from './sensor-adapter.mjs';
22
- import { detectCheckpointFindings } from './runtime-diff-observer.mjs';
22
+ import { captureWorktreeDiff, detectCheckpointFindings } from './runtime-diff-observer.mjs';
23
23
  import {
24
24
  buildIoEscalation, createRunSentinel, probeIoWarning, syncSentinelWatches,
25
25
  } from './runtime-io-sentinel.mjs';
@@ -139,6 +139,9 @@ const KNOWN_ADAPTERS = Object.freeze(['scripted', 'isolated-worktree', 'actual-a
139
139
  * 超えたら`rejected`として理由ごとjournalへ残す(黙って見送らない)。
140
140
  */
141
141
  const IO_ESCALATION_LOCK_TIMEOUT_MS = 15_000;
142
+ /** 走行中workerの完了を待つ上限と間隔。待てないと走行中の観測が成立しない。 */
143
+ const SCRIPTED_OBSERVE_TIMEOUT_MS = 120_000;
144
+ const SCRIPTED_OBSERVE_POLL_MS = 20;
142
145
 
143
146
  class CliContractError extends Error {
144
147
  constructor(code, message, detail) {
@@ -750,6 +753,7 @@ async function driveInitialScriptedManagedEpoch({
750
753
  initialEvents,
751
754
  controlEvents,
752
755
  sentinel = null,
756
+ preDispatchBindings = null,
753
757
  }) {
754
758
  let events = [...initialEvents];
755
759
  const { plan, manifests, executor_packets: packets } = committed.bundle;
@@ -777,9 +781,13 @@ async function driveInitialScriptedManagedEpoch({
777
781
  // 木がTODOごとに分かれて初めて、書き込みの帰属をrootから決められる。
778
782
  const worktreeByTodo = new Map();
779
783
  for (const todoId of frontier) {
780
- worktreeByTodo.set(todoId, await ensureScriptedWorktree({
784
+ const worktreePath = await ensureScriptedWorktree({
781
785
  repoRoot, runDir, packet: packets[todoId],
782
- }));
786
+ });
787
+ worktreeByTodo.set(todoId, worktreePath);
788
+ preDispatchBindings?.set(todoId, {
789
+ worktree_path: worktreePath, base_sha: packets[todoId].base_sha,
790
+ });
783
791
  }
784
792
  syncSentinelWatches({ sentinel, runningTodoIds: [...frontier],
785
793
  rootOf: (todoId) => worktreeByTodo.get(todoId) });
@@ -869,23 +877,37 @@ async function driveInitialScriptedManagedEpoch({
869
877
  event.kind === 'executor_dispatched'
870
878
  && event.payload?.executor_handle === executorHandle
871
879
  ));
872
- const response = await managedSupervisor.route('observe', controllerId, {
873
- executor_handle: executorHandle,
874
- expected_epoch: dispatch.plan_epoch,
875
- expected_lease_digest: dispatch.payload.write_lease_digest,
876
- });
877
- if (response.observation.state !== 'terminal') {
878
- throw new ManagedRuntimeError(
879
- 'ADAPTER_CONTROLLER_UNAVAILABLE',
880
- `scripted controllerがterminal以外を返した: ${response.observation.state}`,
881
- );
880
+ // workerはdispatchで終わらない。走り続けている間はrunningが返るので、
881
+ // 完了まで待つ。ここが待てないと、走行中の観測が成立する構成を持てない。
882
+ const deadline = Date.now() + SCRIPTED_OBSERVE_TIMEOUT_MS;
883
+ for (;;) {
884
+ const response = await managedSupervisor.route('observe', controllerId, {
885
+ executor_handle: executorHandle,
886
+ expected_epoch: dispatch.plan_epoch,
887
+ expected_lease_digest: dispatch.payload.write_lease_digest,
888
+ });
889
+ if (response.observation.state === 'terminal') {
890
+ const receipt = await readScriptedControllerReceipt({
891
+ runDir,
892
+ controllerId,
893
+ payloadDigest: response.observation.payload_digest,
894
+ });
895
+ return { state: 'terminal', receipt };
896
+ }
897
+ if (response.observation.state !== 'running') {
898
+ throw new ManagedRuntimeError(
899
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
900
+ `scripted controllerが未知のstateを返した: ${response.observation.state}`,
901
+ );
902
+ }
903
+ if (Date.now() >= deadline) {
904
+ throw new ManagedRuntimeError(
905
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
906
+ `scripted workerが時間内に終わらない: ${executorHandle}`,
907
+ );
908
+ }
909
+ await new Promise((resolve) => { setTimeout(resolve, SCRIPTED_OBSERVE_POLL_MS); });
882
910
  }
883
- const receipt = await readScriptedControllerReceipt({
884
- runDir,
885
- controllerId,
886
- payloadDigest: response.observation.payload_digest,
887
- });
888
- return { state: 'terminal', receipt };
889
911
  },
890
912
  };
891
913
  const dispatched = await dispatchReadyFrontier({
@@ -1738,6 +1760,14 @@ export async function runManagedSupervisorDaemon({
1738
1760
  * probeが撮るのはgitから読んだ本物のdiffなので、そのままfindingの証拠になる。
1739
1761
  * fs eventをfindingへ昇格させる必要が無く、契約を1つも緩めずに済む。
1740
1762
  */
1763
+ /**
1764
+ * dispatch event耐久化より前のobservation binding。
1765
+ *
1766
+ * frontierのworktreeを用意した時点でsupervisorが知っている値を置く。警報はdispatchの
1767
+ * 最中に飛ぶので、eventの耐久化を待つと最も早い観測を取り逃す。
1768
+ */
1769
+ const preDispatchBindings = new Map();
1770
+
1741
1771
  const probeWarning = async (warning) => {
1742
1772
  const events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events')
1743
1773
  .catch(() => null);
@@ -1749,7 +1779,12 @@ export async function runManagedSupervisorDaemon({
1749
1779
  for (const todoId of warning.todo_ids) {
1750
1780
  const dispatch = events.findLast((event) => event.kind === 'executor_dispatched'
1751
1781
  && event.subject?.kind === 'todo' && event.subject.ref === todoId);
1752
- const binding = dispatch?.payload?.direct_os_observation_binding;
1782
+ // dispatch eventは、frontier全体のdispatchが終わってから一括で耐久化される。
1783
+ // 警報はその最中に飛ぶので、eventだけを頼りにすると**いちばん早い観測を必ず取り逃す**。
1784
+ // supervisorはdispatchの前にworktreeを用意した時点で同じbindingを知っているので、
1785
+ // そちらを先に見る。値はどちらも同一で、早いか遅いかの違いしかない。
1786
+ const binding = dispatch?.payload?.direct_os_observation_binding
1787
+ ?? preDispatchBindings.get(todoId);
1753
1788
  if (typeof binding?.worktree_path !== 'string' || typeof binding?.base_sha !== 'string') continue;
1754
1789
  roots[todoId] = binding.worktree_path;
1755
1790
  // 観測できないTODOは「書いていない」へ丸めない。probeIoWarningが未観測として扱う。
@@ -1788,7 +1823,7 @@ export async function runManagedSupervisorDaemon({
1788
1823
  * 事後に再読して再導出できる主張であることの担保である。fs eventは取りこぼすし再読もできない。
1789
1824
  * ここで残すのは「機械が気づいた」という事実だけで、判定の正本はcheckpointのままである。
1790
1825
  *
1791
- * 記録しない選択肢は無い。気づいたのに黙っている状態を残さない(ADR 0130)。
1826
+ * 記録しない選択肢は無い。気づいたのに黙っている状態を残さない。
1792
1827
  */
1793
1828
  const warningDigestOf = (warning) => digestArtifact({
1794
1829
  warning_kind: warning.kind, todo_ids: [...warning.todo_ids].sort(), path: warning.path,
@@ -2090,6 +2125,7 @@ export async function runManagedSupervisorDaemon({
2090
2125
  sentinel,
2091
2126
  initialEvents: events,
2092
2127
  controlEvents: () => controlEvents,
2128
+ preDispatchBindings,
2093
2129
  });
2094
2130
  }
2095
2131
  if (restarting) {
@@ -158,7 +158,7 @@ export function validateRuntimeControlEventPayload(kind, value) {
158
158
  }
159
159
  // I/O sentinelの早期警報(ADR 0143)。**findingではない**——検知の正本はcheckpointのままで、
160
160
  // これは「早くcheckpointを撮って確かめろ」という引き金の記録である。記録しない選択肢は無い:
161
- // 機械が何かに気づいたのに黙っている状態を残さない(ADR 0130)。
161
+ // 機械が何かに気づいたのに黙っている状態を残さない。
162
162
  if (kind === 'io_warning_observed') {
163
163
  return exact(value, ['warning_kind', 'todo_ids', 'path', 'probe_outcome', 'warning_digest'])
164
164
  && ['io_overlap_warning', 'io_scope_warning'].includes(value.warning_kind)
@@ -251,15 +251,19 @@ function deterministicWriteBytes(packet, relativePath) {
251
251
  })}\n`);
252
252
  }
253
253
 
254
- async function executePacket({ packet, repoRoot }) {
254
+ async function executePacket({ packet, repoRoot, extraWrites = [] }) {
255
255
  const writes = packet.scope?.writes;
256
256
  if (!Array.isArray(writes) || writes.length === 0
257
257
  || writes.some((entry, index) => !safeWritePath(entry)
258
258
  || (index > 0 && writes[index - 1] >= entry))) {
259
259
  fail('SCRIPTED_PACKET_REJECTED', 'scope.writesは非空の昇順一意pathでなければならない');
260
260
  }
261
+ // 宣言scope外への書き込みは、検知そのものを検証するために要る。writeがすべて宣言内に
262
+ // 収まる限り、実行時competitionは原理的に一度も起きないので、検知経路を実runで通せない。
263
+ // 宣言と実writeが食い違う状態を意図して作れることが、この面の受入条件である。
264
+ const allWrites = [...new Set([...writes, ...extraWrites])].sort();
261
265
  const prepared = [];
262
- for (const relativePath of writes) {
266
+ for (const relativePath of allWrites) {
263
267
  const target = await requireSafeTarget(repoRoot, relativePath);
264
268
  const existedAtBase = await basePathExists(repoRoot, packet.base_sha, relativePath);
265
269
  prepared.push({
@@ -318,6 +322,66 @@ async function readAndValidateGate(runDir, writeLease) {
318
322
  * supervisor controller protocolを実装する決定論的scripted adapter。
319
323
  * 時刻はheartbeat schedulingだけに使い、write bytes/handle/receiptへ混入させない。
320
324
  */
325
+ /**
326
+ * 登録済みadapter configから、このcontrollerの振る舞いを読む。
327
+ *
328
+ * **configはregistrationのdigestへ束縛されている。** repo内の任意のfileを信用するのではなく、
329
+ * `run adapter register`が記録した`config_digest`と一致するbytesだけを受ける。一致しなければ
330
+ * 既定の振る舞いへ落とすのではなく止める——registrationと実configがずれた状態で走ると、
331
+ * 記録されたrunの再現性が壊れる。
332
+ *
333
+ * 読むのは3つだけ:
334
+ * - `hold_ms`: 書き込み後にworkerが走り続ける時間。実行時観測が成立する窓を作る。
335
+ * - `extra_writes`: 宣言scope外へのwrite。競合検知そのものを検証するために要る。
336
+ * - `mode`: 既存の`deterministic`のみ。未知の値は黙って無視せず止める。
337
+ */
338
+ async function readScriptedBehavior(repoRoot) {
339
+ const descriptorPath = path.join(repoRoot, '.lattice', 'runtime', 'adapter-registry',
340
+ 'descriptors', 'scripted.json');
341
+ let descriptor;
342
+ try {
343
+ descriptor = JSON.parse(await readFile(descriptorPath, 'utf8'));
344
+ } catch {
345
+ // 未登録のまま走らせる経路(unit test等)は既定の振る舞いで動かす。
346
+ return { hold_ms: 0, extra_writes: [] };
347
+ }
348
+ if (typeof descriptor?.config_ref !== 'string' || typeof descriptor.config_digest !== 'string') {
349
+ return { hold_ms: 0, extra_writes: [] };
350
+ }
351
+ const configPath = path.join(repoRoot, ...descriptor.config_ref.split('/'));
352
+ let bytes;
353
+ try {
354
+ bytes = await readFile(configPath);
355
+ } catch {
356
+ fail('SCRIPTED_BOOTSTRAP_INVALID', '登録済みadapter configを読めない', {
357
+ config_ref: descriptor.config_ref,
358
+ });
359
+ }
360
+ if (sha256Bytes(bytes) !== descriptor.config_digest) {
361
+ fail('SCRIPTED_BOOTSTRAP_INVALID', 'adapter configが登録時のdigestと一致しない', {
362
+ config_ref: descriptor.config_ref,
363
+ });
364
+ }
365
+ let config;
366
+ try {
367
+ config = JSON.parse(bytes.toString('utf8'));
368
+ } catch {
369
+ fail('SCRIPTED_BOOTSTRAP_INVALID', 'adapter configのJSONが不正');
370
+ }
371
+ if (config?.mode !== undefined && config.mode !== 'deterministic') {
372
+ fail('SCRIPTED_BOOTSTRAP_INVALID', `未知のscripted mode: ${String(config.mode)}`);
373
+ }
374
+ const holdMs = config?.hold_ms ?? 0;
375
+ if (!Number.isSafeInteger(holdMs) || holdMs < 0 || holdMs > 600_000) {
376
+ fail('SCRIPTED_BOOTSTRAP_INVALID', 'hold_msが不正');
377
+ }
378
+ const extraWrites = config?.extra_writes ?? [];
379
+ if (!Array.isArray(extraWrites) || extraWrites.some((entry) => !safeWritePath(entry))) {
380
+ fail('SCRIPTED_BOOTSTRAP_INVALID', 'extra_writesが不正');
381
+ }
382
+ return { hold_ms: holdMs, extra_writes: [...extraWrites] };
383
+ }
384
+
321
385
  export async function createScriptedAdapterController({
322
386
  bootstrap,
323
387
  runDir,
@@ -373,6 +437,7 @@ export async function createScriptedAdapterController({
373
437
  descriptor_digest: '',
374
438
  }, 'descriptor_digest');
375
439
  const sessionNonceDigest = digestArtifact(bootstrap.supervisor_session_nonce);
440
+ const behavior = await readScriptedBehavior(canonicalRepoRoot);
376
441
  const stagedLeases = new Map();
377
442
  const armedLeases = new Map();
378
443
  const preparedPackets = new Map();
@@ -551,40 +616,66 @@ export async function createScriptedAdapterController({
551
616
  worktree_path: worktreePath,
552
617
  });
553
618
  }
554
- const { observedDiff, checkpointDigest } = await executePacket({
555
- packet,
556
- repoRoot: await realpath(worktreePath),
557
- });
558
619
  const executorHandle = `scripted-${packet.packet_digest.slice(0, 24)}`;
559
620
  const worktreeId = scriptedWorktreeId(packet);
560
- const receipt = sign({
561
- schema: 'lattice.executor_receipt.v1',
562
- receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
621
+ // **dispatchで作業を終わらせない。** 終わらせると、走行中のTODOが1つも存在しない
622
+ // runになり、実行時の観測——書き込みを見て、他のworkerとの重なりを掴む——が
623
+ // 原理的に成立しない。dispatchは作業を起こして返り、完了はobserveが拾う。
624
+ const progress = {
625
+ schema: 'lattice.scripted_adapter_progress.v1',
626
+ run_id: bootstrap.run_id,
627
+ todo_id: packet.todo_id,
563
628
  executor_handle: executorHandle,
564
629
  worktree_id: worktreeId,
565
- base_sha: packet.base_sha,
566
- plan_epoch: packet.plan_epoch,
567
- packet_digest: packet.packet_digest,
568
- todo_id: packet.todo_id,
569
- checkpoint_digest: checkpointDigest,
570
- observed_diff: observedDiff,
571
- receipt_digest: '',
572
- }, 'receipt_digest');
573
- if (!validateExecutorReceipt(receipt)) {
574
- fail('SCRIPTED_EXECUTION_FAILED', '生成receiptがexecutor contractを満たさない');
575
- }
576
- const payloadDigest = await persistReceipt(receipt);
630
+ state: 'running',
631
+ };
577
632
  const task = {
578
633
  packet: structuredClone(packet),
579
634
  lease: structuredClone(writeLease),
580
635
  executorHandle,
581
636
  worktreeId,
582
- receipt,
583
- payloadDigest,
584
- state: 'terminal',
637
+ receipt: null,
638
+ payloadDigest: digestArtifact(progress),
639
+ state: 'running',
640
+ failure: null,
641
+ settled: null,
585
642
  };
586
643
  tasks.set(executorHandle, task);
587
644
  todoToHandle.set(packet.todo_id, executorHandle);
645
+ const worktreeReal = await realpath(worktreePath);
646
+ task.settled = (async () => {
647
+ try {
648
+ const { observedDiff, checkpointDigest } = await executePacket({
649
+ packet, repoRoot: worktreeReal, extraWrites: behavior.extra_writes,
650
+ });
651
+ // 書いた後も走り続ける。これが実行時観測の窓であり、0ならば窓は存在しない。
652
+ if (behavior.hold_ms > 0) {
653
+ await new Promise((resolve) => { setTimeout(resolve, behavior.hold_ms); });
654
+ }
655
+ const receipt = sign({
656
+ schema: 'lattice.executor_receipt.v1',
657
+ receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
658
+ executor_handle: executorHandle,
659
+ worktree_id: worktreeId,
660
+ base_sha: packet.base_sha,
661
+ plan_epoch: packet.plan_epoch,
662
+ packet_digest: packet.packet_digest,
663
+ todo_id: packet.todo_id,
664
+ checkpoint_digest: checkpointDigest,
665
+ observed_diff: observedDiff,
666
+ receipt_digest: '',
667
+ }, 'receipt_digest');
668
+ if (!validateExecutorReceipt(receipt)) {
669
+ throw new TypeError('生成receiptがexecutor contractを満たさない');
670
+ }
671
+ task.payloadDigest = await persistReceipt(receipt);
672
+ task.receipt = receipt;
673
+ task.state = 'terminal';
674
+ } catch (error) {
675
+ // 失敗を走行中のまま放置しない。observeがtypedに落ちる形へ残す。
676
+ task.failure = String(error?.detail?.reason ?? error?.message ?? error);
677
+ }
678
+ })();
588
679
  return sign({
589
680
  schema: 'lattice.adapter_dispatch_response.v1',
590
681
  request_id: request.request_id,
@@ -602,6 +693,10 @@ export async function createScriptedAdapterController({
602
693
  || task.lease.lease_digest !== request.expected_lease_digest) {
603
694
  fail('SCRIPTED_OBSERVATION_REJECTED', 'observe bindingがdispatch記録と一致しない');
604
695
  }
696
+ // 走行中に落ちた作業を「まだ走っている」と言い続けない。
697
+ if (task.failure !== null) {
698
+ fail('SCRIPTED_EXECUTION_FAILED', 'worker実行が失敗した', { reason: task.failure });
699
+ }
605
700
  const observation = sign({
606
701
  schema: 'lattice.adapter_observation.v1',
607
702
  state: task.state,
@@ -641,11 +736,20 @@ export async function createScriptedAdapterController({
641
736
  }, 'response_digest');
642
737
  }
643
738
  if (operation === 'barrier') {
644
- const acks = request.running_bindings.map((binding) => {
739
+ const acks = [];
740
+ for (const binding of request.running_bindings) {
645
741
  const task = tasks.get(binding.executor_handle);
646
742
  if (task === undefined || task.packet.todo_id !== binding.todo_id) {
647
743
  fail('SCRIPTED_BARRIER_REJECTED', 'barrierが未知のrunning bindingを含む');
648
744
  }
745
+ // barrierは静止の宣言である。走行中の作業を残したままackを返すと、
746
+ // 「止まった」と言いながらworktreeが動き続ける。settleを待ってから答える。
747
+ if (task.settled !== null) await task.settled;
748
+ if (task.failure !== null) {
749
+ fail('SCRIPTED_BARRIER_REJECTED', 'barrier対象のworker実行が失敗している', {
750
+ reason: task.failure,
751
+ });
752
+ }
649
753
  task.state = 'held';
650
754
  const processObservationDigest = digestArtifact({
651
755
  schema: 'lattice.scripted_process_observation.v1',
@@ -657,7 +761,7 @@ export async function createScriptedAdapterController({
657
761
  worktree_id: task.worktreeId,
658
762
  checkpoint_digest: task.receipt.checkpoint_digest,
659
763
  });
660
- return sign({
764
+ acks.push(sign({
661
765
  schema: 'lattice.executor_quiescence_ack.v1',
662
766
  ack_id: `barrier-${binding.packet_digest.slice(0, 24)}`,
663
767
  run_id: bootstrap.run_id,
@@ -673,8 +777,8 @@ export async function createScriptedAdapterController({
673
777
  worktree_fingerprint_digest: worktreeFingerprintDigest,
674
778
  supervisor_session_nonce_digest: sessionNonceDigest,
675
779
  ack_digest: '',
676
- }, 'ack_digest');
677
- });
780
+ }, 'ack_digest'));
781
+ }
678
782
  return sign({
679
783
  schema: 'lattice.adapter_barrier_response.v1',
680
784
  request_id: request.request_id,