@quolu/lattice 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,6 +66,7 @@ function sha16(value) {
66
66
  const WITNESS_KINDS = Object.freeze(['state', 'schema', 'invariant', 'effect', 'external_effect']);
67
67
  const HEX_DIGEST = /^[0-9a-f]{64}$/u;
68
68
  const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
69
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
69
70
 
70
71
  function selfDigestValid(value, field) {
71
72
  return plainRecord(value) && HEX_DIGEST.test(value[field] ?? '')
@@ -532,6 +533,67 @@ export function decideHoldAndCarryOver(options = {}) {
532
533
  * predeclared treatmentがfindingのpath集合を覆う場合だけseam laneを返し、
533
534
  * それ以外(shared state/effect・未宣言path競合)はintentional serialにする。
534
535
  */
536
+ /**
537
+ * 同じ競合が何epochにわたって観測されたかを数える。
538
+ *
539
+ * 過去epochのconflictを再seedしないguardは既に在るが、**新しく観測された同じ競合**は毎epoch
540
+ * seedされる。原因が続く限り「hold→再計画→再開→また同じ競合」が繰り返せる。
541
+ * 誤帰属でも、scope違反を繰り返すworkerでも、変換で解けない競合でも同じことが起きる。
542
+ *
543
+ * 鍵は種別・資源・関与task対である。plan_epochで数えるのは、同一epoch内の複数回観測を
544
+ * 繰り返しと数えないためで、再計画を1回挟んで再び現れたことだけを繰り返しとする。
545
+ */
546
+ export function countConflictRecurrence(events = []) {
547
+ const epochsByKey = new Map();
548
+ for (const event of events) {
549
+ if (event?.kind !== 'conflict_found') continue;
550
+ const finding = event.payload ?? {};
551
+ if (typeof finding.kind !== 'string' || !Array.isArray(finding.todo_ids)) continue;
552
+ const key = [
553
+ finding.kind,
554
+ typeof finding.path === 'string' ? finding.path : '',
555
+ [...finding.todo_ids].sort(compareText).join(','),
556
+ ].join('\u0000');
557
+ if (!epochsByKey.has(key)) epochsByKey.set(key, new Set());
558
+ epochsByKey.get(key).add(event.plan_epoch);
559
+ }
560
+ return epochsByKey;
561
+ }
562
+
563
+ /**
564
+ * 再計画で解けていない競合。1つでもあれば、もう一度同じ処置を試しても収束しない。
565
+ *
566
+ * 既定の閾値を3とするのは、1回目は通常の競合、2回目は再計画が効かなかった可能性(順序の綾を
567
+ * 含む)、3回目で「同じことが繰り返されている」と言えるためである。直列化で誤魔化さない——
568
+ * 誤帰属が原因なら直列化しても解けず、解けないことを解けたように見せることになる。
569
+ */
570
+ export const NON_CONVERGENT_EPOCH_THRESHOLD = 3;
571
+
572
+ export function detectNonConvergentConflicts(options = {}) {
573
+ if (!exactRecord(options, ['events']) && !exactRecord(options, ['events', 'threshold'])) {
574
+ fail('detectNonConvergentConflicts optionsがexact shapeでない');
575
+ }
576
+ const { events, threshold = NON_CONVERGENT_EPOCH_THRESHOLD } = options;
577
+ if (!Array.isArray(events)) fail('eventsがarrayでない');
578
+ if (!Number.isSafeInteger(threshold) || threshold < 2) fail('thresholdが2以上の整数でない');
579
+ const recurrence = countConflictRecurrence(events);
580
+ const entries = [];
581
+ for (const [key, epochs] of recurrence) {
582
+ if (epochs.size < threshold) continue;
583
+ const [kind, resource, todoIds] = key.split('\u0000');
584
+ entries.push({
585
+ kind,
586
+ resource,
587
+ todo_ids: todoIds === '' ? [] : todoIds.split(','),
588
+ epochs: [...epochs].sort((left, right) => left - right),
589
+ });
590
+ }
591
+ return entries.sort((left, right) => compareText(
592
+ `${left.kind}\u0000${left.resource}\u0000${left.todo_ids.join(',')}`,
593
+ `${right.kind}\u0000${right.resource}\u0000${right.todo_ids.join(',')}`,
594
+ ));
595
+ }
596
+
535
597
  export function routeConflictTreatment(options = {}) {
536
598
  if (!exactRecord(options, ['finding', 'predeclaredTreatments'])) {
537
599
  fail('routeConflictTreatment optionsがexact shapeでない');
@@ -567,6 +629,14 @@ export function recompileNextEpochPlan(options = {}) {
567
629
  if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
568
630
  if (!validateHoldDecision(holdDecision)) fail('holdDecisionがcontractを満たさない');
569
631
  if (!Array.isArray(additionalConflicts)) fail('additionalConflictsがarrayではない');
632
+ // 同じ競合が閾値のepoch数だけ繰り返しているなら、もう一度同じ処置を試しても収束しない。
633
+ // 直列化やもう1周で誤魔化さず、解けていないことをtypedに述べて止める。
634
+ const nonConvergent = detectNonConvergentConflicts({ events });
635
+ if (nonConvergent.length > 0) {
636
+ fail(`再計画で解けていない競合がある(非収束): ${nonConvergent
637
+ .map((entry) => `${entry.kind}:${entry.resource}:${entry.todo_ids.join(',')}@${entry.epochs.join('/')}`)
638
+ .join(' ')}`);
639
+ }
570
640
 
571
641
  const state = projectRuntimeState({ events });
572
642
  if (state.freeze === null) fail('freeze中でないprefixからrecompileできない');
@@ -92,6 +92,27 @@ export async function observeMacosBinaryIdentity(binaryPath) {
92
92
  }
93
93
 
94
94
  /** storeが解決したimmutable bindingからprocess/worktree/checkpointをDirect OSで再観測する。 */
95
+ /**
96
+ * 本repositoryの現在状態を1つのdigestへ畳む。
97
+ *
98
+ * HEAD・作業ツリー状態(untracked/ignoredを含む)・全refを見る。workerがworktreeの外へ
99
+ * 書いてもworktreeのdiffには映らないので、ここだけが本repositoryへの書き込みを捕まえる。
100
+ */
101
+ export async function canonicalRepositoryFingerprint(repoRoot) {
102
+ const parts = [];
103
+ for (const args of [
104
+ ['rev-parse', 'HEAD'],
105
+ ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching'],
106
+ ['for-each-ref', '--format=%(refname) %(objectname)'],
107
+ ]) {
108
+ const { stdout } = await execFileAsync('git', args, {
109
+ cwd: repoRoot, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
110
+ });
111
+ parts.push(stdout);
112
+ }
113
+ return createHash('sha256').update(parts.join('\u0000'), 'utf8').digest('hex');
114
+ }
115
+
95
116
  export function createDirectOsProcessObserver({ resolveObservationBinding }) {
96
117
  if (typeof resolveObservationBinding !== 'function') fail('SUPERVISOR_CONFIGURATION_INVALID', 'observation binding resolverなし');
97
118
  return async ({ kind, binding, ack }) => {
@@ -104,6 +125,15 @@ export function createDirectOsProcessObserver({ resolveObservationBinding }) {
104
125
  || typeof resolved.base_sha !== 'string' || !/^[0-9a-f]{40}$/.test(resolved.base_sha)
105
126
  || !validateProcessStartIdentity(resolved.process_start_identity)
106
127
  || resolved.process_start_identity.pid !== resolved.process_pid) fail('HOLD_ACKS_INCOMPLETE', 'observation binding不正');
128
+ // 本repositoryを見る材料は対で渡す。片方だけでは照合できず、片方だけを受けると
129
+ // 「検査した」と読める記録が検査なしで作れてしまう。
130
+ const canonicalRoot = resolved.canonical_root ?? null;
131
+ const canonicalBaseline = resolved.canonical_fingerprint_digest ?? null;
132
+ if ((canonicalRoot === null) !== (canonicalBaseline === null)
133
+ || (canonicalRoot !== null && !path.isAbsolute(canonicalRoot))
134
+ || (canonicalBaseline !== null && !/^[0-9a-f]{64}$/u.test(canonicalBaseline))) {
135
+ fail('HOLD_ACKS_INCOMPLETE', 'canonical repository観測bindingが不正');
136
+ }
107
137
  let processState = 'exited';
108
138
  let observedIdentity = resolved.process_start_identity;
109
139
  let processGroupId = resolved.process_group_id;
@@ -145,7 +175,17 @@ export function createDirectOsProcessObserver({ resolveObservationBinding }) {
145
175
  const checkpoint = await captureWorktreeDiff({ worktreePath: worktreeRealpath, baseSha: resolved.base_sha });
146
176
  const processObservation = { schema: 'lattice.direct_process_observation.v1', pid: resolved.process_pid, process_start_identity_digest: observedIdentity.identity_digest, process_group_id: processGroupId, state: processState };
147
177
  const processObservationDigest = digestArtifact(processObservation);
148
- const worktreeFingerprint = { schema: 'lattice.direct_worktree_fingerprint.v1', worktree_id: binding?.worktree_id ?? ack.worktree_id, worktree_realpath: worktreeRealpath, checkpoint_digest: checkpoint.checkpoint_digest };
178
+ // worktreeの外——本repository——への書き込みは、worktreeのdiffにはまったく映らない。
179
+ // 見ていないことを「変更が無かった」と読ませないため、検査したかどうかを記録へ残す
180
+ // (ADR 0140)。渡されていなければ`null`=未検査であり、無変更の主張ではない。
181
+ let canonicalDigest = null;
182
+ if (canonicalRoot !== null) {
183
+ canonicalDigest = await canonicalRepositoryFingerprint(canonicalRoot);
184
+ if (canonicalDigest !== canonicalBaseline) {
185
+ fail('HOLD_ACKS_INCOMPLETE', 'canonical repositoryがworker実行中に変化した');
186
+ }
187
+ }
188
+ const worktreeFingerprint = { schema: 'lattice.direct_worktree_fingerprint.v2', worktree_id: binding?.worktree_id ?? ack.worktree_id, worktree_realpath: worktreeRealpath, checkpoint_digest: checkpoint.checkpoint_digest, canonical_fingerprint_digest: canonicalDigest };
149
189
  const worktreeFingerprintDigest = digestArtifact(worktreeFingerprint);
150
190
  return {
151
191
  quiesced: true,