@quolu/lattice 0.46.2 → 0.47.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.
@@ -235,14 +235,18 @@ const WITNESS_PROVENANCE = Object.freeze([
235
235
  ]);
236
236
 
237
237
  /**
238
- * 現行のrun request契約。v3は`owns[].creates`だけがv1との差であり、境界宣言としては同値である。
239
- * 既存requestの書き換えを要求しないため、v1は読み口として残す。
238
+ * 現行のrun request契約。v4は計画境界を予測として扱う。v3以前の厳密なcompile契約は、
239
+ * 既存requestの意味を変えないため読み口として残す。
240
240
  *
241
241
  * v2はこの系列ではない。ADR 0064のepoch後継request(`predecessor_request_digest`と
242
242
  * `task_migration_digest`を持つ別shape)が既に使っている番号なので、飛ばして採番する。
243
243
  */
244
- export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v3';
245
- export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze(['lattice.run_request.v1']);
244
+ export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v4';
245
+ export const RUN_REQUEST_DECLARATIVE_SCHEMA = 'lattice.run_request.v3';
246
+ export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze([
247
+ RUN_REQUEST_DECLARATIVE_SCHEMA,
248
+ 'lattice.run_request.v1',
249
+ ]);
246
250
  export const RUN_REQUEST_SCHEMAS = Object.freeze([
247
251
  RUN_REQUEST_SCHEMA,
248
252
  ...RUN_REQUEST_LEGACY_SCHEMAS,
@@ -305,7 +309,7 @@ export function explainRunRequest(value) {
305
309
  if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
306
310
  if (!RUN_REQUEST_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
307
311
  // 創作宣言はv2から。v1のclosed shapeは余分fieldを拒否するので加算互換が成立しない。
308
- const allowCreates = value.schema === RUN_REQUEST_SCHEMA;
312
+ const allowCreates = [RUN_REQUEST_SCHEMA, RUN_REQUEST_DECLARATIVE_SCHEMA].includes(value.schema);
309
313
  if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
310
314
  if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
311
315
  if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
@@ -124,12 +124,14 @@ function witnessSet(manifest, kinds) {
124
124
  * unknown findingにする。
125
125
  */
126
126
  export function classifyObservedDiff(options = {}) {
127
- const { plan, manifests, observations } = options;
127
+ const { plan, manifests, observations, relevantTodoIds = null } = options;
128
128
  requirePlan(plan);
129
129
  if (manifests === null || typeof manifests !== 'object' || Array.isArray(manifests)
130
- || !Array.isArray(observations)) {
130
+ || !Array.isArray(observations)
131
+ || !(relevantTodoIds === null || Array.isArray(relevantTodoIds))) {
131
132
  invalidVerification('manifests/observationsが不正');
132
133
  }
134
+ const relevant = new Set(relevantTodoIds ?? Object.keys(manifests));
133
135
 
134
136
  const findings = [];
135
137
  const observedByTodo = new Map();
@@ -187,7 +189,8 @@ export function classifyObservedDiff(options = {}) {
187
189
  for (const [todoId, paths] of observedByTodo) {
188
190
  for (const [otherId, manifest] of Object.entries(manifests)) {
189
191
  if (otherId === todoId) continue;
190
- const otherDeclared = manifest.writes ?? [];
192
+ if (!relevant.has(otherId)) continue;
193
+ const otherDeclared = [...(manifest.reads ?? []), ...(manifest.writes ?? [])];
191
194
  for (const path of paths) {
192
195
  if (!declaredWriteCovers(otherDeclared, path)) continue;
193
196
  const pair = sorted([todoId, otherId]);
@@ -453,6 +456,24 @@ export function recomputeReceiptDecisions(options = {}) {
453
456
  const dispatch = dispatchEventForReceipt === undefined
454
457
  ? undefined
455
458
  : { sequence: dispatchEventForReceipt.sequence, payload: dispatchEventForReceipt.payload };
459
+ const originBindingRetained = (() => {
460
+ if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= plan.plan_epoch) return false;
461
+ for (let epoch = receipt.plan_epoch; epoch < plan.plan_epoch; epoch += 1) {
462
+ const witnessed = events.some((event) => event.sequence < receipt.sequence
463
+ && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
464
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
465
+ const continued = events.some((event) => event.sequence < receipt.sequence
466
+ && event.kind === 'hold_decided' && event.plan_epoch === epoch
467
+ && event.payload?.continue_set?.includes(receipt.todo_id));
468
+ const recompiled = events.some((event) => event.sequence < receipt.sequence
469
+ && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
470
+ const invalidated = events.some((event) => event.sequence < receipt.sequence
471
+ && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
472
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
473
+ if (!witnessed || !continued || !recompiled || invalidated) return false;
474
+ }
475
+ return true;
476
+ })();
456
477
  if (dispatch === undefined) {
457
478
  return reject('not_dispatched');
458
479
  }
@@ -465,12 +486,12 @@ export function recomputeReceiptDecisions(options = {}) {
465
486
  if (typeof plan.base_sha === 'string' && payload.base_sha !== plan.base_sha) {
466
487
  return reject('base_mismatch');
467
488
  }
468
- if (receipt.plan_epoch !== plan.plan_epoch) {
489
+ if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
469
490
  return reject('epoch_mismatch');
470
491
  }
471
492
  // dispatchが旧epochのTODOが現epoch receiptを名乗る場合はepoch_rebound必須
472
493
  // (rebindなしのepoch自称を受理しない。Decision 7.3/7.4)。
473
- if (dispatchEventForReceipt.plan_epoch !== plan.plan_epoch) {
494
+ if (dispatchEventForReceipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
474
495
  const rebound = state.rebinds[receipt.todo_id];
475
496
  if (rebound === undefined
476
497
  || rebound.payload?.new_plan_epoch !== plan.plan_epoch
@@ -391,6 +391,22 @@ export async function observeExecutor(options = {}) {
391
391
  if (!validateExecutorReceipt(observation.receipt)) {
392
392
  fail(`terminal receiptがexecutor_receipt.v1 contractを満たさない: ${todoId}`);
393
393
  }
394
+ if (observation.checkpoint !== undefined) {
395
+ if (!plainRecord(observation.checkpoint)
396
+ || typeof observation.checkpoint.checkpoint_digest !== 'string'
397
+ || !/^[0-9a-f]{64}$/.test(observation.checkpoint.checkpoint_digest)) {
398
+ fail(`terminal checkpointにはcheckpoint_digestが必要: ${todoId}`);
399
+ }
400
+ next.push(buildNextRunEvent({
401
+ events: next,
402
+ runId,
403
+ kind: 'checkpoint_observed',
404
+ planEpoch: observation.receipt.plan_epoch,
405
+ subject: { kind: 'todo', ref: todoId },
406
+ payload: structuredClone(observation.checkpoint),
407
+ recordedAt,
408
+ }));
409
+ }
394
410
  next.push(buildNextRunEvent({
395
411
  events: next,
396
412
  runId,
@@ -481,13 +497,19 @@ export function classifyCheckpointObservation(options = {}) {
481
497
  const checkpoints = state.checkpoints.filter((entry) => entry.todo_id === todoId);
482
498
  if (checkpoints.length === 0) fail(`checkpoint未観測のTODOを分類できない: ${todoId}`);
483
499
  const checkpoint = checkpoints[checkpoints.length - 1].payload;
484
- const { findings } = detect({
500
+ const { findings: detectedFindings } = detect({
485
501
  todoId,
486
502
  checkpoint,
487
503
  packets,
488
504
  runningTodoIds: state.running,
489
505
  });
490
- if (!Array.isArray(findings)) fail('detectがfindings配列を返さない');
506
+ if (!Array.isArray(detectedFindings)) fail('detectがfindings配列を返さない');
507
+ const observations = detectedFindings
508
+ .filter((finding) => finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1)
509
+ .map((finding) => ({ ...finding, kind: 'prediction_excess' }));
510
+ const findings = detectedFindings.filter((finding) => !(
511
+ finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1
512
+ ));
491
513
  // 再分類のidempotence: 既に保存済みのfinding(kind+todo_ids+path)は再記録しない。
492
514
  const recordedKeys = new Set(state.conflicts.map((conflict) => (
493
515
  `${conflict.kind}|${[...(conflict.todo_ids ?? [])].sort().join(',')}|${conflict.path ?? ''}`
@@ -521,7 +543,7 @@ export function classifyCheckpointObservation(options = {}) {
521
543
  recordedAt,
522
544
  }));
523
545
  }
524
- return { events: next, findings: freshFindings };
546
+ return { events: next, findings: freshFindings, observations };
525
547
  }
526
548
 
527
549
  const RECEIPT_BINDING_FIELDS = Object.freeze([
@@ -549,6 +571,25 @@ function witnessProvenReceiptBinding(state, receipt) {
549
571
  ));
550
572
  }
551
573
 
574
+ function retainsOriginBinding(events, receipt, currentEpoch) {
575
+ if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= currentEpoch) return false;
576
+ for (let epoch = receipt.plan_epoch; epoch < currentEpoch; epoch += 1) {
577
+ const witnessed = events.some((event) => event.sequence < receipt.sequence
578
+ && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
579
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
580
+ const continued = events.some((event) => event.sequence < receipt.sequence
581
+ && event.kind === 'hold_decided' && event.plan_epoch === epoch
582
+ && event.payload?.continue_set?.includes(receipt.todo_id));
583
+ const recompiled = events.some((event) => event.sequence < receipt.sequence
584
+ && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
585
+ const invalidated = events.some((event) => event.sequence < receipt.sequence
586
+ && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
587
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
588
+ if (!witnessed || !continued || !recompiled || invalidated) return false;
589
+ }
590
+ return true;
591
+ }
592
+
552
593
  export function adjudicatePendingReceipts(options = {}) {
553
594
  if (!exactRecord(options, ['runId', 'plan', 'events', 'recordedAt'])) {
554
595
  fail('adjudicatePendingReceipts optionsがexact shapeでない');
@@ -577,6 +618,7 @@ export function adjudicatePendingReceipts(options = {}) {
577
618
  if (receipt.accepted_sequence !== null || receipt.rejected_sequence !== null) continue;
578
619
  const payload = receipt.payload ?? {};
579
620
  const dispatch = state.dispatches[receipt.todo_id];
621
+ const originBindingRetained = retainsOriginBinding(events, receipt, plan.plan_epoch);
580
622
  let rejection = null;
581
623
  if (seenReceiptIds.has(receipt.receipt_id)) {
582
624
  rejection = 'duplicate_receipt_id';
@@ -590,7 +632,7 @@ export function adjudicatePendingReceipts(options = {}) {
590
632
  rejection = 'binding_mismatch';
591
633
  } else if (payload.base_sha !== plan.base_sha) {
592
634
  rejection = 'base_mismatch';
593
- } else if (receipt.plan_epoch !== plan.plan_epoch) {
635
+ } else if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
594
636
  rejection = 'epoch_mismatch';
595
637
  } else if ((() => {
596
638
  // dispatchが旧epochのTODOが現epochのreceiptを名乗る場合、epoch_rebound
@@ -602,7 +644,8 @@ export function adjudicatePendingReceipts(options = {}) {
602
644
  && event.subject.ref === receipt.todo_id
603
645
  && event.sequence === dispatch.sequence
604
646
  ));
605
- if (dispatchEvent === undefined || dispatchEvent.plan_epoch === plan.plan_epoch) return false;
647
+ if (originBindingRetained || dispatchEvent === undefined
648
+ || dispatchEvent.plan_epoch === plan.plan_epoch) return false;
606
649
  const rebound = state.rebinds[receipt.todo_id];
607
650
  return rebound === undefined
608
651
  || rebound.payload?.new_plan_epoch !== plan.plan_epoch
@@ -7,6 +7,7 @@ import { compileSchedulabilityGraphV2 } from './schedulability-compiler-v2.mjs';
7
7
  import { verifySchedulabilityPlanV2 } from './schedulability-verifier-v2.mjs';
8
8
  import {
9
9
  BOUNDARY_MANIFEST_SCHEMA,
10
+ RUN_REQUEST_SCHEMA,
10
11
  SENSOR_EXPECT_KINDS,
11
12
  SENSOR_QUERY_OPERATIONS,
12
13
  selfDigest,
@@ -447,6 +448,7 @@ export function compileRuntimePlanV1(options = {}) {
447
448
  const outcomeByQueryId = normalizeEvidence(sensorEvidence, queryById);
448
449
 
449
450
  const todoIds = request.todos.map((todo) => todo.todo_id);
451
+ const predictionsOnly = request.schema === RUN_REQUEST_SCHEMA;
450
452
 
451
453
  // witnessの束縛を解決する。query set外の参照と、expect↔query targetの
452
454
  // 不一致(別targetのreceiptへの再ラベル)はQUERY_DRIFT。
@@ -458,7 +460,7 @@ export function compileRuntimePlanV1(options = {}) {
458
460
  const creating = new Set(witness.owns
459
461
  .filter((own) => own.creates === true).map((own) => own.target));
460
462
  const bindings = normalizeProvenanceQueries(witness, todoId)
461
- .map((binding) => (creating.size > 0
463
+ .map((binding) => (!predictionsOnly && creating.size > 0
462
464
  && [...creating].some((target) => bindingCoversOwn(binding, { kind: 'path', target }))
463
465
  ? { ...binding, creates: true } : binding));
464
466
  for (const binding of bindings) {
@@ -498,7 +500,7 @@ export function compileRuntimePlanV1(options = {}) {
498
500
  for (const todoId of todoIds) {
499
501
  for (const binding of bindingsByTodo.get(todoId)) {
500
502
  const resolved = resolveBindingStatus(binding, outcomeByQueryId.get(binding.query_id));
501
- if (resolved !== 'ready') {
503
+ if (!predictionsOnly && resolved !== 'ready') {
502
504
  unknowns.push({ todo_id: todoId, kind: `sensor_${resolved}`, ref: binding.query_id });
503
505
  }
504
506
  }
@@ -544,7 +546,7 @@ export function compileRuntimePlanV1(options = {}) {
544
546
  });
545
547
  }
546
548
  }
547
- if (affectedDrift.length > 0) {
549
+ if (!predictionsOnly && affectedDrift.length > 0) {
548
550
  return nonDispatchable('AFFECTED_TEST_DRIFT', { mismatches: affectedDrift });
549
551
  }
550
552
 
@@ -570,14 +572,16 @@ export function compileRuntimePlanV1(options = {}) {
570
572
  ambiguous.push({ target: own.target, query_ids: [seen, binding.query_id].sort(compareText) });
571
573
  }
572
574
  }
573
- if (covering.length === 0) {
575
+ if (!predictionsOnly && covering.length === 0) {
574
576
  unknowns.push({ todo_id: todoId, kind: 'sensor_unbound', ref: `${own.kind}:${own.target}` });
575
577
  }
576
578
  }
577
579
  }
578
580
  if (ambiguous.length > 0) return nonDispatchable('QUERY_DRIFT', { ambiguous_targets: ambiguous });
579
581
 
580
- // 宣言write scopeの交差はownership解決なしでは安全と推測できない(unknownへ)。
582
+ // 最新契約ではwrite scopeは予測である。既知の交差はserial conflictへ使うが、
583
+ // 予測が足りないこと自体はunknownへ落とさない。
584
+ const predictedWriteGroups = [];
581
585
  for (let left = 0; left < todoIds.length; left += 1) {
582
586
  for (let right = left + 1; right < todoIds.length; right += 1) {
583
587
  const leftWitness = request.manual_witness[todoIds[left]];
@@ -591,16 +595,21 @@ export function compileRuntimePlanV1(options = {}) {
591
595
  // resource経由で扱われる。owns解決のない交差だけをunknownにする。
592
596
  if (leftOwnPaths.has(leftPath) && rightOwnPaths.has(rightPath)
593
597
  && leftPath === rightPath) continue;
594
- unknowns.push({
595
- todo_id: todoIds[left],
596
- kind: 'undeclared_write_overlap',
597
- ref: `${leftPath} ${rightPath}`,
598
- });
599
- unknowns.push({
600
- todo_id: todoIds[right],
601
- kind: 'undeclared_write_overlap',
602
- ref: `${leftPath} ${rightPath}`,
603
- });
598
+ if (predictionsOnly) {
599
+ predictedWriteGroups.push({
600
+ target: [leftPath, rightPath].sort(compareText).join(''),
601
+ todoIds: [todoIds[left], todoIds[right]],
602
+ });
603
+ } else {
604
+ unknowns.push({
605
+ todo_id: todoIds[left], kind: 'undeclared_write_overlap',
606
+ ref: `${leftPath} ${rightPath}`,
607
+ });
608
+ unknowns.push({
609
+ todo_id: todoIds[right], kind: 'undeclared_write_overlap',
610
+ ref: `${leftPath} ${rightPath}`,
611
+ });
612
+ }
604
613
  }
605
614
  }
606
615
  }
@@ -661,6 +670,7 @@ export function compileRuntimePlanV1(options = {}) {
661
670
  if (representative !== undefined) break;
662
671
  }
663
672
  if (representative === undefined) fail(`covering binding不整合: ${group.own.target}`);
673
+ if (predictionsOnly && resolveBindingStatus(representative, outcome) !== 'ready') continue;
664
674
  const status = statusOutcome.status !== 'ready'
665
675
  ? statusOutcome.status
666
676
  : resolveBindingStatus(representative, outcome);
@@ -722,6 +732,15 @@ export function compileRuntimePlanV1(options = {}) {
722
732
  provenance: [manualProvenance(request)],
723
733
  });
724
734
  }
735
+ for (const [index, group] of predictedWriteGroups.entries()) {
736
+ resources.push({
737
+ resource_id: `predicted-ww-${sha16(`${group.target}:${index}`)}`,
738
+ kind: 'state',
739
+ target: group.target,
740
+ todo_ids: group.todoIds,
741
+ provenance: [manualProvenance(request)],
742
+ });
743
+ }
725
744
 
726
745
  let dynamicIndex = 0;
727
746
  for (const unknown of unknowns) {
@@ -301,6 +301,11 @@ function computeAffectedClosure(plan, manifests, seedTodoIds) {
301
301
  return closure;
302
302
  }
303
303
 
304
+ /** runtime findingから、停止・再計画する作業群だけを取り出す。 */
305
+ export function affectedTodoIds(plan, manifests, seedTodoIds) {
306
+ return sorted(computeAffectedClosure(plan, manifests, seedTodoIds));
307
+ }
308
+
304
309
  /**
305
310
  * carry-over witness documentを構築し、提供sourcesに対して自己実証する。
306
311
  * 実証できない場合はnullでなくreasons付きの失敗を返す(呼び出し側がholdへ戻す)。
@@ -754,9 +759,9 @@ export function recompileNextEpochPlan(options = {}) {
754
759
  recordedAt,
755
760
  }));
756
761
 
757
- // 旧contextの全失効(Decision 7: 例外なく一斉失効。carried-overは失効後に
758
- // rebindで「内容同一性を証明した新epochへの再認可」を受ける)。
759
- for (const todoId of sorted([...holdDecision.hold_set, ...holdDecision.continue_set])) {
762
+ // 失効するのは停止・再計画対象だけ。closure外のrunning TODOはorigin bindingのまま
763
+ // 継続するため、contextもpartial patchも無効化しない。
764
+ for (const todoId of sorted(holdDecision.hold_set)) {
760
765
  next.push(buildNextRunEvent({
761
766
  events: next,
762
767
  runId,
@@ -766,7 +771,7 @@ export function recompileNextEpochPlan(options = {}) {
766
771
  payload: {
767
772
  old_plan_ref: plan.plan_ref,
768
773
  invalidated: ['agent_context', 'partial_patch', 'interface_assumption', 'boundary_evidence'],
769
- reauthorized_via: holdDecision.continue_set.includes(todoId) ? 'epoch_rebind' : 'redispatch',
774
+ reauthorized_via: 'redispatch',
770
775
  },
771
776
  recordedAt,
772
777
  }));
@@ -788,7 +793,8 @@ export function recompileNextEpochPlan(options = {}) {
788
793
  .map((event) => event.sequence)
789
794
  .sort((left, right) => left - right)[0] ?? null;
790
795
 
791
- // carried-over TODOへのepoch rebind packet(content不変・epoch/plan refだけ更新)。
796
+ // restart時は全process barrierになるため、その場合だけcarry-overを現epochへ復元できる
797
+ // rebind packetをbundleへ残す。通常のrecompileでは使わずorigin bindingを維持する。
792
798
  const rebindPackets = {};
793
799
  for (const todoId of holdDecision.continue_set) {
794
800
  const packet = packets[todoId];
@@ -262,6 +262,7 @@ export class RuntimeManagedSupervisor {
262
262
  #gate = null;
263
263
  #previousGate = null;
264
264
  #releaseAcks = [];
265
+ #gateRecords = new Map();
265
266
  #frozen = false;
266
267
  #gateGeneration = 0;
267
268
 
@@ -321,10 +322,8 @@ export class RuntimeManagedSupervisor {
321
322
  || !Number.isSafeInteger(sequence) || sequence <= record.lastHeartbeatSequence || !digest(leaseSetDigest)) {
322
323
  await this.#failClosed(record, 'CONTROLLER_HEARTBEAT_EXPIRED', 'heartbeat binding不正');
323
324
  }
324
- const expectedLeaseSetDigest = digestArtifact([...this.#leases.values()]
325
- .filter((entry) => entry.controllerId === controllerId && !entry.revoked)
326
- .map((entry) => entry.lease.lease_digest).sort());
327
- if (leaseSetDigest !== expectedLeaseSetDigest) await this.#failClosed(record, 'CONTROLLER_HEARTBEAT_EXPIRED', 'heartbeat lease set不一致');
325
+ // prepare/release応答とsupervisor投影の間には短い非同期窓がある。heartbeatは
326
+ // livenessだけを担い、lease集合はwrite認可時のcentral gate full-chainで照合する。
328
327
  record.lastHeartbeat = this.#clock();
329
328
  record.lastHeartbeatSequence = sequence;
330
329
  await this.#append('controller_heartbeat', { controller_id: controllerId, registration_digest: registrationDigest, sequence, lease_set_digest: leaseSetDigest });
@@ -366,13 +365,30 @@ export class RuntimeManagedSupervisor {
366
365
 
367
366
  /** 全running bindingを一件も省略せずbarrierし、controller ackと独立OS観測を照合する。 */
368
367
  async barrierAll({ barrierId, reason, frozenEventDigest }) {
368
+ return this.#barrier({ barrierId, reason, frozenEventDigest, targetTodoIds: null });
369
+ }
370
+
371
+ /** findingの影響群だけを静止し、無関係なrunning bindingはそのまま通す。 */
372
+ async barrierSelected({ barrierId, reason, frozenEventDigest, todoIds }) {
373
+ if (!Array.isArray(todoIds) || todoIds.length === 0
374
+ || todoIds.some((todoId) => !identifier(todoId))
375
+ || new Set(todoIds).size !== todoIds.length) {
376
+ fail('HOLD_ACKS_INCOMPLETE', '対象barrierのTODO集合が不正');
377
+ }
378
+ return this.#barrier({ barrierId, reason, frozenEventDigest,
379
+ targetTodoIds: new Set(todoIds) });
380
+ }
381
+
382
+ async #barrier({ barrierId, reason, frozenEventDigest, targetTodoIds }) {
369
383
  const durableBindings = await this.#runningBindingResolver({ runId: this.#runId, frozenEventDigest });
370
384
  if (!identifier(barrierId) || typeof reason !== 'string' || reason.length === 0
371
385
  || !digest(frozenEventDigest) || !Array.isArray(durableBindings)
372
386
  || !durableBindings.every(validateRunningBinding)) fail('HOLD_ACKS_INCOMPLETE', 'durable running binding不正');
373
387
  await this.assertControllerHealth();
374
388
  this.#frozen = true;
375
- const runningBindings = await this.#resolveRunningUnion({ durableBindings, frozenEventDigest });
389
+ const allRunningBindings = await this.#resolveRunningUnion({ durableBindings, frozenEventDigest });
390
+ const runningBindings = targetTodoIds === null ? allRunningBindings
391
+ : allRunningBindings.filter((binding) => targetTodoIds.has(binding.todo_id));
376
392
  const unique = new Set(runningBindings.map((binding) => binding.todo_id));
377
393
  if (unique.size !== runningBindings.length) fail('HOLD_ACKS_INCOMPLETE', 'running TODO重複');
378
394
  const barrierControlDigest = await this.#append('barrier_requested', { barrier_id: barrierId,
@@ -413,7 +429,9 @@ export class RuntimeManagedSupervisor {
413
429
  }
414
430
  }
415
431
  if (acknowledgements.length !== runningBindings.length) fail('HOLD_ACKS_INCOMPLETE', '未登録controller所有bindingあり');
416
- const residualBindings = await this.#collectControllerRunning({ frozenEventDigest });
432
+ const observedAfterBarrier = await this.#collectControllerRunning({ frozenEventDigest });
433
+ const residualBindings = targetTodoIds === null ? observedAfterBarrier
434
+ : observedAfterBarrier.filter((binding) => targetTodoIds.has(binding.todo_id));
417
435
  if (residualBindings.length !== 0) {
418
436
  fail('HOLD_ACKS_INCOMPLETE', `barrier後もrunning executor残存: ${residualBindings.map((binding) => binding.todo_id).join(',')}`);
419
437
  }
@@ -460,10 +478,14 @@ export class RuntimeManagedSupervisor {
460
478
  return observed;
461
479
  }
462
480
 
463
- /** finding/store永続化後に呼ぶtyped hold閉路。全running quiescence以外はheldを返さない。 */
464
- async holdConflict({ findingDigest, frozenEventDigest, barrierId, reason, recordedAt }) {
481
+ /** finding/store永続化後に呼ぶtyped hold閉路。対象群のquiescence完了後だけheldを返す。 */
482
+ async holdConflict({ findingDigest, frozenEventDigest, barrierId, reason, recordedAt,
483
+ todoIds = null }) {
465
484
  if (!digest(findingDigest) || typeof recordedAt !== 'string' || Number.isNaN(Date.parse(recordedAt))) fail('FINDING_UNRESOLVED', 'hold finding binding不正');
466
- const acknowledgements = await this.barrierAll({ barrierId, reason, frozenEventDigest });
485
+ const acknowledgements = todoIds === null
486
+ ? await this.barrierAll({ barrierId, reason, frozenEventDigest })
487
+ : await this.barrierSelected({ barrierId, reason, frozenEventDigest,
488
+ todoIds });
467
489
  const result = {
468
490
  schema: 'lattice.runtime_hold_result.v1', run_id: this.#runId,
469
491
  finding_digest: findingDigest, barrier_id: barrierId,
@@ -635,6 +657,10 @@ export class RuntimeManagedSupervisor {
635
657
  this.#previousGate = this.#gate;
636
658
  this.#gate = gate;
637
659
  this.#releaseAcks = releaseAcks.map((ack) => structuredClone(ack));
660
+ this.#gateRecords.set(gate.gate_generation, {
661
+ gate: structuredClone(gate),
662
+ releaseAcks: releaseAcks.map((ack) => structuredClone(ack)),
663
+ });
638
664
  this.#gateGeneration = nextGeneration;
639
665
  this.#frozen = false;
640
666
  return { gate: structuredClone(gate), armedLeases: armed.map((lease) => structuredClone(lease)) };
@@ -644,19 +670,32 @@ export class RuntimeManagedSupervisor {
644
670
  async authorizeWrite({ leaseDigest }) {
645
671
  await this.assertControllerHealth();
646
672
  const entry = this.#leases.get(leaseDigest);
647
- if (this.#frozen || !entry || entry.revoked || !validateArmedWriteLease(entry.lease) || this.#gate === null) fail('RUN_FROZEN', '有効なarmed leaseなし');
673
+ if (this.#frozen || !entry || entry.revoked || !validateArmedWriteLease(entry.lease)) fail('RUN_FROZEN', '有効なarmed leaseなし');
674
+ const gateRecord = this.#gateRecords.get(entry.lease.gate_generation);
675
+ const gate = gateRecord?.gate ?? (this.#gate?.gate_generation === entry.lease.gate_generation
676
+ ? this.#gate : null);
677
+ const releaseAcks = gateRecord?.releaseAcks ?? (gate === this.#gate ? this.#releaseAcks : []);
678
+ if (gate === null) fail('RUN_FROZEN', 'leaseのorigin gateが無い');
679
+ const previousGate = this.#gateRecords.get(gate.gate_generation - 1)?.gate
680
+ ?? (gate === this.#gate ? this.#previousGate : null);
681
+ const gateLeases = [...this.#leases.values()]
682
+ .filter((item) => !item.revoked && validateArmedWriteLease(item.lease)
683
+ && item.lease.gate_generation === gate.gate_generation)
684
+ .map((item) => item.lease);
648
685
  const verified = verifyCentralWriteGate({
649
- gate: this.#gate, runId: this.#runId, planEpoch: entry.lease.plan_epoch,
686
+ gate, runId: this.#runId, planEpoch: entry.lease.plan_epoch,
650
687
  releaseBarrierDigest: entry.lease.release_barrier_digest, sessionNonceDigest: this.#sessionNonceDigest,
651
688
  registrations: [...this.#controllers.values()].map((record) => record.registration),
652
689
  controllers: [...this.#controllers.values()].map((record) => record.descriptor),
653
- releaseAcks: this.#releaseAcks, armedLeases: [...this.#leases.values()].filter((item) => !item.revoked && validateArmedWriteLease(item.lease)).map((item) => item.lease),
654
- previousGate: this.#previousGate,
690
+ releaseAcks, armedLeases: gateLeases,
691
+ previousGate,
655
692
  });
656
- if (!verified.valid || this.#clock() - entry.issuedAt > entry.lease.ttl_ms) {
693
+ // gate commit時にarmした後続frontierのleaseを壁時計で失効させると、正しく直列待ちした
694
+ // taskほどdispatch不能になる。freshnessはactive gate chainとrevokeで決める。
695
+ if (!verified.valid) {
657
696
  entry.revoked = true;
658
697
  this.#frozen = true;
659
- fail('RUN_FROZEN', verified.valid ? 'lease TTL超過' : verified.reason);
698
+ fail('RUN_FROZEN', verified.reason);
660
699
  }
661
700
  return structuredClone(entry.lease);
662
701
  }
@@ -340,7 +340,8 @@ async function readAndValidateGate(runDir, writeLease) {
340
340
  *
341
341
  * 読むのは3つだけ:
342
342
  * - `hold_ms`: 書き込み後にworkerが走り続ける時間。実行時観測が成立する窓を作る。
343
- * - `extra_writes`: 宣言scope外へのwrite。競合検知そのものを検証するために要る。
343
+ * - `extra_writes`: 全TODOに共通する宣言scopewrite
344
+ * - `extra_writes_by_todo`: 競合当事者だけへscope外writeを与える実daemon fixture用設定。
344
345
  * - `mode`: 既存の`deterministic`のみ。未知の値は黙って無視せず止める。
345
346
  */
346
347
  async function readScriptedBehavior(repoRoot) {
@@ -351,10 +352,10 @@ async function readScriptedBehavior(repoRoot) {
351
352
  descriptor = JSON.parse(await readFile(descriptorPath, 'utf8'));
352
353
  } catch {
353
354
  // 未登録のまま走らせる経路(unit test等)は既定の振る舞いで動かす。
354
- return { hold_ms: 0, extra_writes: [] };
355
+ return { hold_ms: 0, extra_writes: [], extra_writes_by_todo: {} };
355
356
  }
356
357
  if (typeof descriptor?.config_ref !== 'string' || typeof descriptor.config_digest !== 'string') {
357
- return { hold_ms: 0, extra_writes: [] };
358
+ return { hold_ms: 0, extra_writes: [], extra_writes_by_todo: {} };
358
359
  }
359
360
  const configPath = path.join(repoRoot, ...descriptor.config_ref.split('/'));
360
361
  let bytes;
@@ -387,7 +388,15 @@ async function readScriptedBehavior(repoRoot) {
387
388
  if (!Array.isArray(extraWrites) || extraWrites.some((entry) => !safeWritePath(entry))) {
388
389
  fail('SCRIPTED_BOOTSTRAP_INVALID', 'extra_writesが不正');
389
390
  }
390
- return { hold_ms: holdMs, extra_writes: [...extraWrites] };
391
+ const extraWritesByTodo = config?.extra_writes_by_todo ?? {};
392
+ if (!plain(extraWritesByTodo) || Object.entries(extraWritesByTodo).some(([todoId, entries]) => (
393
+ !ID.test(todoId) || !Array.isArray(entries) || entries.some((entry) => !safeWritePath(entry))
394
+ ))) {
395
+ fail('SCRIPTED_BOOTSTRAP_INVALID', 'extra_writes_by_todoが不正');
396
+ }
397
+ return { hold_ms: holdMs, extra_writes: [...extraWrites],
398
+ extra_writes_by_todo: Object.fromEntries(Object.entries(extraWritesByTodo)
399
+ .map(([todoId, entries]) => [todoId, [...entries]])) };
391
400
  }
392
401
 
393
402
  const WORKER_ENTRYPOINT = fileURLToPath(new URL('../bin/lattice-scripted-worker.mjs', import.meta.url));
@@ -675,20 +684,34 @@ export async function createScriptedAdapterController({
675
684
  const priorHandle = todoToHandle.get(packet.todo_id);
676
685
  if (priorHandle !== undefined) {
677
686
  const prior = tasks.get(priorHandle);
678
- if (prior.packet.packet_digest !== packet.packet_digest
679
- || prior.lease.lease_digest !== writeLease.lease_digest) {
687
+ if (prior.packet.packet_digest === packet.packet_digest
688
+ && prior.lease.lease_digest === writeLease.lease_digest) {
689
+ return sign({
690
+ schema: 'lattice.adapter_dispatch_response.v2',
691
+ request_id: request.request_id,
692
+ executor_handle: prior.executorHandle,
693
+ worktree_id: prior.worktreeId,
694
+ packet_digest: packet.packet_digest,
695
+ lease_digest: writeLease.lease_digest,
696
+ worker_process: workerProcessOf(prior),
697
+ response_digest: '',
698
+ }, 'response_digest');
699
+ }
700
+ if (prior.state !== 'held') {
680
701
  fail('SCRIPTED_DUPLICATE_DISPATCH', '同じTODOへ異なるpacketをdispatchできない');
681
702
  }
682
- return sign({
683
- schema: 'lattice.adapter_dispatch_response.v2',
684
- request_id: request.request_id,
685
- executor_handle: prior.executorHandle,
686
- worktree_id: prior.worktreeId,
687
- packet_digest: packet.packet_digest,
688
- lease_digest: writeLease.lease_digest,
689
- worker_process: workerProcessOf(prior),
690
- response_digest: '',
691
- }, 'response_digest');
703
+ // 対象限定holdで失効したattemptだけを畳み、同じTODOのsuccessorを起動する。
704
+ // closure外attemptはtodoToHandleを保持するので、この経路へ入らない。
705
+ try { process.kill(-prior.worker.process_group_id, 'SIGKILL'); }
706
+ catch (error) {
707
+ if (error?.code !== 'ESRCH') {
708
+ fail('SCRIPTED_EXECUTION_FAILED', '旧attemptを終了できない', {
709
+ pid: prior.worker.pid, reason: String(error?.code ?? error?.message ?? error),
710
+ });
711
+ }
712
+ }
713
+ prior.state = 'superseded';
714
+ todoToHandle.delete(packet.todo_id);
692
715
  }
693
716
  await readAndValidateGate(canonicalRunDir, writeLease);
694
717
  // canonical repoではなく自分の木へ書く。共有rootでは、書き込みの帰属をrootから
@@ -722,7 +745,8 @@ export async function createScriptedAdapterController({
722
745
  // `detached`で独立process groupへ置く。同じgroupにcontrollerが居ると、直接OS観測が
723
746
  // 「未記録process group memberを検出」として正しく落とす。
724
747
  const spawned = await spawnScriptedWorker({
725
- packet, worktreePath: worktreeReal, extraWrites: behavior.extra_writes,
748
+ packet, worktreePath: worktreeReal,
749
+ extraWrites: behavior.extra_writes_by_todo[packet.todo_id] ?? behavior.extra_writes,
726
750
  holdMs: behavior.hold_ms, controllerId, runDir: canonicalRunDir,
727
751
  });
728
752
  const task = {
@@ -1038,9 +1062,9 @@ export async function runScriptedAdapterController({
1038
1062
  registrationDigest = digest;
1039
1063
  if (heartbeatTimer !== null) return;
1040
1064
  heartbeatTimer = setInterval(() => {
1041
- if (socket.destroyed) return;
1065
+ if (persistentSocket === null || persistentSocket.destroyed) return;
1042
1066
  heartbeatSequence += 1;
1043
- send(socket, sign({
1067
+ send(persistentSocket, sign({
1044
1068
  schema: 'lattice.adapter_controller_heartbeat.v1',
1045
1069
  controller_id: controller.controllerId,
1046
1070
  registration_digest: registrationDigest,
@@ -23,6 +23,7 @@ import { affectedTestsFromEvidence } from './runtime-front-end.mjs';
23
23
  import { explainSeamGate } from './seam-gate.mjs';
24
24
  import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
25
25
  import { todoSelfDigest } from './todo-contracts.mjs';
26
+ import { TODO_WITNESS_SET_SCHEMA } from './todo-independence-contracts.mjs';
26
27
 
27
28
  export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
28
29
  // v2は翻訳段(`reconciled`)の追加である。宣言を観測へ合わせてから判定するようになったので、
@@ -184,7 +185,7 @@ export function reconcileWitnessToObservation({
184
185
  /**
185
186
  * 実行時witnessへconcern anchorを足してtodo witness setにする。
186
187
  *
187
- * 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
188
+ * 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v4`)。持たせるのでなく、
188
189
  * 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
189
190
  * 契約に書けるものではないからである。
190
191
  *
@@ -213,7 +214,7 @@ export function buildRuntimeSeamWitnessSet({
213
214
  };
214
215
  }
215
216
  const witnessSet = {
216
- schema: 'lattice.todo_witness_set.v3',
217
+ schema: TODO_WITNESS_SET_SCHEMA,
217
218
  project_id: SYNTHETIC_PLAN_KEY,
218
219
  plan_key: SYNTHETIC_PLAN_KEY,
219
220
  capacity: { executors },
@@ -315,6 +316,7 @@ export async function resolveRuntimeSeam({
315
316
  latticeBin,
316
317
  sharedPathFor: () => declaration.path_names.shared,
317
318
  executors: request.capacity.executors,
319
+ precedences: bundle.plan.precedence,
318
320
  pathNames,
319
321
  compileIndependence: {
320
322
  baseArtifact,