@quolu/lattice 0.32.0 → 0.34.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.
Files changed (64) hide show
  1. package/README.ja.md +11 -1
  2. package/README.md +11 -2
  3. package/bin/lattice.mjs +12 -1
  4. package/package.json +1 -1
  5. package/sensor/dist/bin/lattice-sensor.js +72 -4
  6. package/sensor/dist/db/migrations.d.ts +1 -1
  7. package/sensor/dist/db/migrations.d.ts.map +1 -1
  8. package/sensor/dist/db/migrations.js +42 -2
  9. package/sensor/dist/db/migrations.js.map +1 -1
  10. package/sensor/dist/db/queries.d.ts +18 -0
  11. package/sensor/dist/db/queries.d.ts.map +1 -1
  12. package/sensor/dist/db/queries.js +76 -9
  13. package/sensor/dist/db/queries.js.map +1 -1
  14. package/sensor/dist/db/schema.sql +10 -1
  15. package/sensor/dist/extraction/extraction-version.d.ts +1 -1
  16. package/sensor/dist/extraction/extraction-version.d.ts.map +1 -1
  17. package/sensor/dist/extraction/extraction-version.js +5 -1
  18. package/sensor/dist/extraction/extraction-version.js.map +1 -1
  19. package/sensor/dist/extraction/index.d.ts +2 -0
  20. package/sensor/dist/extraction/index.d.ts.map +1 -1
  21. package/sensor/dist/extraction/index.js +38 -3
  22. package/sensor/dist/extraction/index.js.map +1 -1
  23. package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
  24. package/sensor/dist/extraction/tree-sitter.js +89 -16
  25. package/sensor/dist/extraction/tree-sitter.js.map +1 -1
  26. package/sensor/dist/index.d.ts +8 -1
  27. package/sensor/dist/index.d.ts.map +1 -1
  28. package/sensor/dist/index.js +9 -0
  29. package/sensor/dist/index.js.map +1 -1
  30. package/sensor/dist/resolution/import-resolver.d.ts +11 -0
  31. package/sensor/dist/resolution/import-resolver.d.ts.map +1 -1
  32. package/sensor/dist/resolution/import-resolver.js +13 -5
  33. package/sensor/dist/resolution/import-resolver.js.map +1 -1
  34. package/sensor/dist/resolution/index.d.ts.map +1 -1
  35. package/sensor/dist/resolution/index.js +11 -0
  36. package/sensor/dist/resolution/index.js.map +1 -1
  37. package/sensor/dist/resolution/types.d.ts +4 -0
  38. package/sensor/dist/resolution/types.d.ts.map +1 -1
  39. package/sensor/dist/types.d.ts +22 -0
  40. package/sensor/dist/types.d.ts.map +1 -1
  41. package/src/artifact-contracts.mjs +3 -0
  42. package/src/cli-help.mjs +6 -0
  43. package/src/rc3-scripted-campaign.mjs +3 -1
  44. package/src/runtime-cli.mjs +280 -36
  45. package/src/runtime-contracts.mjs +1 -1
  46. package/src/runtime-control-store.mjs +24 -1
  47. package/src/runtime-decision-verifier.mjs +1 -1
  48. package/src/runtime-diff-observer.mjs +2 -2
  49. package/src/runtime-front-end.mjs +47 -13
  50. package/src/runtime-hold-recompile.mjs +31 -2
  51. package/src/runtime-io-sentinel.mjs +16 -9
  52. package/src/runtime-managed-supervisor.mjs +32 -3
  53. package/src/runtime-multi-epoch-store.mjs +2 -2
  54. package/src/runtime-projection.mjs +27 -0
  55. package/src/runtime-scripted-adapter-controller.mjs +22 -1
  56. package/src/runtime-seam-resolve.mjs +156 -13
  57. package/src/runtime-seam-treatment.mjs +2 -1
  58. package/src/seam-apply.mjs +101 -4
  59. package/src/seam-cost.mjs +322 -0
  60. package/src/seam-gate.mjs +146 -0
  61. package/src/seam-rewrite.mjs +102 -16
  62. package/src/seam-verification.mjs +26 -3
  63. package/src/sensor-adapter.mjs +6 -1
  64. package/src/todo-cli.mjs +55 -0
@@ -28,7 +28,7 @@ import { selfDigest } from './runtime-contracts.mjs';
28
28
  import { coveredBy } from './runtime-diff-observer.mjs';
29
29
 
30
30
  /** 警報の種別。findingのkindとは別空間にする——findingへ昇格するのはprobeを通った後だけである。 */
31
- export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_scope_warning']);
31
+ export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_undeclared_write_warning']);
32
32
 
33
33
  /**
34
34
  * 監視から外すrepo相対prefix。
@@ -65,7 +65,7 @@ export function isExcludedPath(relativePath, excludes = DEFAULT_IO_EXCLUDES) {
65
65
  *
66
66
  * checkpoint findingの2述語をそのまま1 pathへ適用する:
67
67
  * - 他のrunning TODOの宣言scopeに入るpathへ書いた → `io_overlap_warning`
68
- * - 自分の宣言scopeの外へ書いた → `io_scope_warning`
68
+ * - 自分の宣言scopeの外へ書いた → `io_undeclared_write_warning`
69
69
  *
70
70
  * @returns {{warnings: Array<{kind: string, todo_ids: string[], path: string}>}}
71
71
  */
@@ -84,7 +84,7 @@ export function classifyIoObservation(options = {}) {
84
84
 
85
85
  const warnings = [];
86
86
  if (!coveredBy(packet.scope.writes, relativePath)) {
87
- warnings.push({ kind: 'io_scope_warning', todo_ids: [todoId], path: relativePath });
87
+ warnings.push({ kind: 'io_undeclared_write_warning', todo_ids: [todoId], path: relativePath });
88
88
  }
89
89
  for (const otherId of [...runningTodoIds].sort(compareText)) {
90
90
  if (otherId === todoId) continue;
@@ -298,15 +298,22 @@ export function probeIoWarning({ warning, checkpointsByTodo } = {}) {
298
298
  }
299
299
 
300
300
  /**
301
- * 警報kind → finding kind。probeを通った警報だけがこの写像に乗る。
301
+ * 警報kind → finding kind。**重なりだけがhold経路へ乗る。**
302
302
  *
303
- * 述語が同一(`coveredBy`)なので、写像は1対1に決まる。ここで新しい種類を発明しない——
304
- * 発明すると、警報経由のfindingだけ既存の処置(請求項7の直列化、請求項8のseam変換)が
305
- * 効かない種類になってしまう。
303
+ * 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
304
+ * 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
305
+ * 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
306
+ * 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
307
+ *
308
+ * よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
309
+ * それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
310
+ * 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
311
+ *
312
+ * 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
313
+ * 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
306
314
  */
307
315
  const WARNING_FINDING_KIND = Object.freeze({
308
316
  io_overlap_warning: 'observed_write_conflict',
309
- io_scope_warning: 'scope_violation',
310
317
  });
311
318
 
312
319
  /**
@@ -320,7 +327,7 @@ function selectEscalationAnchor({ warning, writers, packets }) {
320
327
  const wrote = new Set(writers);
321
328
  const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
322
329
  if (!wrote.has(todoId)) return false;
323
- if (warning.kind === 'io_scope_warning') return true;
330
+ if (warning.kind === 'io_undeclared_write_warning') return true;
324
331
  return warning.todo_ids.some((otherId) => {
325
332
  if (otherId === todoId) return false;
326
333
  const other = packets[otherId];
@@ -92,6 +92,28 @@ export async function observeMacosBinaryIdentity(binaryPath) {
92
92
  return identity;
93
93
  }
94
94
 
95
+ /**
96
+ * 起動済みprocessが実際に実行しているimageのcanonical pathをOSから観測する。
97
+ * bytesの同一性はcallerがlaunch descriptorのSHA-256と照合する。
98
+ */
99
+ export async function observeProcessExecutablePath(pid) {
100
+ if (!Number.isSafeInteger(pid) || pid < 1) {
101
+ fail('ADAPTER_BINARY_IDENTITY_MISMATCH', 'exec後image PID不正');
102
+ }
103
+ if (process.platform === 'linux') {
104
+ return realpath(`/proc/${pid}/exe`);
105
+ }
106
+ if (process.platform === 'darwin') {
107
+ const { stdout } = await execFileAsync(
108
+ '/bin/ps',
109
+ ['-o', 'comm=', '-p', String(pid)],
110
+ { encoding: 'utf8' },
111
+ );
112
+ return realpath(stdout.trim());
113
+ }
114
+ fail('ADAPTER_BINARY_IDENTITY_MISMATCH', `exec後image path観測未対応platform: ${process.platform}`);
115
+ }
116
+
95
117
  /** storeが解決したimmutable bindingからprocess/worktree/checkpointをDirect OSで再観測する。 */
96
118
  /**
97
119
  * 本repositoryの現在状態を1つのdigestへ畳む。
@@ -862,8 +884,7 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
862
884
  try { process.kill(child.pid, 0); } catch { fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'controller process不達'); }
863
885
  let executedImage;
864
886
  try {
865
- const { stdout } = await execFileAsync('/bin/ps', ['-o', 'comm=', '-p', String(child.pid)], { encoding: 'utf8' });
866
- executedImage = await realpath(stdout.trim());
887
+ executedImage = await observeProcessExecutablePath(child.pid);
867
888
  } catch { fail('ADAPTER_BINARY_IDENTITY_MISMATCH', 'exec後image path観測失敗'); }
868
889
  if (executedImage !== binaryReal || sha256Bytes(await readFile(executedImage)) !== launch.binary_digest) {
869
890
  fail('ADAPTER_BINARY_IDENTITY_MISMATCH', 'exec後image path/bytes不一致');
@@ -1340,7 +1361,15 @@ if (isDirectDaemon) {
1340
1361
  commitPhaseRevision: (revision) => applyPhaseTodoRevision({
1341
1362
  repoRoot: path.resolve(runDir, '..', '..', '..'),
1342
1363
  writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revision,
1343
- actor: 'lattice-runtime-supervisor', recordedAt: new Date().toISOString(),
1364
+ // actorは`{host, session, agent}`のexact recordである(`lattice.todo_event.v4`)。
1365
+ // 文字列を渡していた間、seam_splitの再計画はphase revisionをcommitできなかった
1366
+ // ——工程storeへ書く直前で必ず落ちるので、請求項8の再開まで一度も届いていない。
1367
+ actor: {
1368
+ host: 'lattice-runtime',
1369
+ session: sessionNonce.slice(0, 32),
1370
+ agent: 'lattice-runtime-supervisor',
1371
+ },
1372
+ recordedAt: new Date().toISOString(),
1344
1373
  }),
1345
1374
  });
1346
1375
  })
@@ -83,7 +83,7 @@ export function validateRuntimeFindingRecord(value) {
83
83
  || new Set(finding.evidence_digests).size !== finding.evidence_digests.length
84
84
  || finding.evidence_digests.some((entry, index) => index > 0 && finding.evidence_digests[index - 1] >= entry)
85
85
  || !selfDigestValid(finding, 'finding_digest')) return false;
86
- const pathKind = ['observed_write_conflict', 'scope_violation', 'stale_context'].includes(finding.kind);
86
+ const pathKind = ['observed_write_conflict', 'undeclared_write', 'stale_context'].includes(finding.kind);
87
87
  if (pathKind !== (finding.path !== null) || pathKind === (finding.resource_id !== null)) return false;
88
88
  const observer = value.recorded_by;
89
89
  return exactRecord(observer, ['schema', 'kind', 'controller_registration_digest',
@@ -109,7 +109,7 @@ export function validateRuntimeFindingCandidate(value) {
109
109
  || value.evidence_digests.some((digest, index) => index > 0
110
110
  && value.evidence_digests[index - 1] >= digest)
111
111
  || !selfDigestValid(value, 'candidate_digest')) return false;
112
- const pathKind = ['observed_write_conflict', 'scope_violation', 'stale_context']
112
+ const pathKind = ['observed_write_conflict', 'undeclared_write', 'stale_context']
113
113
  .includes(value.proposed_kind);
114
114
  return pathKind
115
115
  ? typeof value.path === 'string' && value.path.length > 0 && value.resource_id === null
@@ -238,5 +238,32 @@ export function projectRuntimeStatusOverlays(options = {}) {
238
238
  carry_over: members('carry_over'),
239
239
  redispatch: members('redispatch'),
240
240
  intake_frozen: runtimeState.freeze !== null,
241
+ treatment_advice: treatmentAdvice(events),
242
+ };
243
+ }
244
+
245
+ /**
246
+ * 止まっているrunに対して、請求項8(変換)を試せるかを助言する。
247
+ *
248
+ * 拒否ではなく助言である(ADR 0128と同じ規律)。既定の再計画modeが`intentional_serial`だから
249
+ * といって「変換が不可能」ではない——事前宣言が無いだけである。実行時に初めて見つかった競合には
250
+ * 事前宣言が無いので、既定だけを見ると請求項8へ行く道が無いように見える。
251
+ *
252
+ * 装置が言えるのは**係争資源が切れる種類か**までとする。共有stateや外部effectは面を分けても
253
+ * 同じ資源に触り続けるので切れない。pathは分けられる**かもしれない**——実際に切れるかと、
254
+ * それが割に合うかは、隔離worktreeで変換して五条件で測って初めて分かる(`run seam resolve`)。
255
+ * 試すかどうかは費用のかかる判断なので、装置は決めずに材料だけ渡す。
256
+ */
257
+ function treatmentAdvice(events) {
258
+ const conflict = [...events].reverse()
259
+ .find((event) => event.kind === 'conflict_found'
260
+ && event.payload !== null && typeof event.payload === 'object');
261
+ if (conflict === undefined) return null;
262
+ const severability = typeof conflict.payload.path === 'string' ? 'code_seam' : 'serial';
263
+ return {
264
+ finding_digest: typeof conflict.payload.finding_digest === 'string'
265
+ ? conflict.payload.finding_digest : null,
266
+ severability,
267
+ transform_attemptable: severability === 'code_seam',
241
268
  };
242
269
  }
@@ -511,6 +511,21 @@ export async function createScriptedAdapterController({
511
511
  let registrationDigest = null;
512
512
 
513
513
  /** dispatch応答が運ぶworker process。誰を止めればよいかをsupervisorへ名指しする。 */
514
+ /**
515
+ * 起こしたworker processを畳む。
516
+ *
517
+ * **SIGKILLでなければ届かない。** barrierで止めたworkerはSIGSTOP状態にあり、
518
+ * SIGTERMは継続されるまで配送されない。放置すると、runもcontrollerもrepoも消えた後まで
519
+ * 停止したままのprocessが残り続ける(自分では終われない)。
520
+ */
521
+ const reapWorkers = () => {
522
+ for (const task of tasks.values()) {
523
+ const pid = task.worker?.pid;
524
+ if (!Number.isSafeInteger(pid)) continue;
525
+ try { process.kill(pid, 'SIGKILL'); } catch { /* 既に終わっている */ }
526
+ }
527
+ };
528
+
514
529
  const workerProcessOf = (task) => ({
515
530
  pid: task.worker.pid,
516
531
  process_group_id: task.worker.process_group_id,
@@ -904,7 +919,9 @@ export async function createScriptedAdapterController({
904
919
  worktree_id: rebind.worktree_id,
905
920
  predecessor_epoch: rebind.new_plan_epoch - 1,
906
921
  successor_epoch: rebind.new_plan_epoch,
907
- predecessor_packet_digest: task.receipt.packet_digest,
922
+ // **holdされたworkerにreceiptは無い。** 作業を終えていないから止められているので、
923
+ // 完了の記録を前提にできない。前任packetのdigestはdispatch時のpacketが持っている。
924
+ predecessor_packet_digest: task.packet.packet_digest,
908
925
  rebind_packet_digest: rebind.packet_digest,
909
926
  new_write_lease_id: staged.lease_id,
910
927
  supervisor_session_nonce_digest: sessionNonceDigest,
@@ -947,6 +964,7 @@ export async function createScriptedAdapterController({
947
964
  controllerSessionNonce,
948
965
  descriptor: structuredClone(descriptor),
949
966
  heartbeat,
967
+ reapWorkers,
950
968
  leaseSetDigest() {
951
969
  return digestArtifact([...stagedLeases.keys(), ...armedLeases.keys()].sort());
952
970
  },
@@ -1102,6 +1120,9 @@ export async function runScriptedAdapterController({
1102
1120
  const close = async () => {
1103
1121
  if (closed) return;
1104
1122
  closed = true;
1123
+ // 起こしたworkerを道連れにする。停止中のworkerは自分では終われないので、
1124
+ // ここで畳まないとrunもrepoも消えた後まで残り続ける。
1125
+ controller.reapWorkers();
1105
1126
  clearInterval(heartbeatTimer);
1106
1127
  persistentSocket?.destroy();
1107
1128
  await new Promise((resolve) => server.close(resolve));
@@ -19,11 +19,16 @@ import { digestArtifact } from './artifact-contracts.mjs';
19
19
  import { commitSeamTransform } from './seam-commit.mjs';
20
20
  import { applySeamConflict } from './seam-apply.mjs';
21
21
  import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
22
+ import { affectedTestsFromEvidence } from './runtime-front-end.mjs';
23
+ import { explainSeamGate } from './seam-gate.mjs';
22
24
  import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
23
25
  import { todoSelfDigest } from './todo-contracts.mjs';
24
26
 
25
27
  export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
26
- export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v1';
28
+ // v2は翻訳段(`reconciled`)の追加である。宣言を観測へ合わせてから判定するようになったので、
29
+ // どの宣言の上で五条件を見たかが決着の一部になった。v1の形のまま中身を変えると、記録が何に
30
+ // ついてのものか確定しなくなる。
31
+ export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v2';
27
32
 
28
33
  /** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
29
34
  const SYNTHETIC_PLAN_KEY = 'runtime';
@@ -77,21 +82,130 @@ export function validateRuntimeSeamRequest(value) {
77
82
  return value.request_digest === todoSelfDigest(value, 'request_digest');
78
83
  }
79
84
 
85
+ /**
86
+ * 観測された実態へ宣言を合わせる(翻訳段)。
87
+ *
88
+ * 実行時のpath競合は、**片方がその資源を所有していないから起きる**。所有していない資源の内側に
89
+ * 担当は主張できない(`concern_anchor_resource_not_owned`)ので、宣言のままでは変換の入力が
90
+ * 組めない——請求項8は、実行時に見つかった競合の形をそのままでは受け取れなかった。
91
+ *
92
+ * 境界は計画時の**予測**であって、workerを閉じ込める制約ではない。予測を超えたのは予測が狭かった
93
+ * からであり、作業が不正だったからではない。したがって操作は「破った側を直す」ではなく、観測が
94
+ * 示した実態へ宣言を合わせることであり、**対称**である——どちらの足跡が予測を超えたかは観測が
95
+ * 決めるので、ここでは関与TODOを同じ規則で扱う。
96
+ *
97
+ * これは3つ目の処置ではない。翻訳を通ると計画時競合の形(`owns`の交差)になり、そこから先は
98
+ * 既存の請求項7/8がそのまま適用できる。競合辺が立つのは翻訳の副産物ではなく目的である——
99
+ * 立たないままだと`overlap_reduced`が最初から満たされたことになり、変換の検証が無意味になる。
100
+ *
101
+ * 広げるのは観測が示した資源だけとする。findingが持つ係争pathも関与TODOも観測であって推定では
102
+ * ない。広げた事実は呼び出し側へ返す。黙って広げると、予測が外れたことも、判定がどの宣言の上で
103
+ * 行われたかも残らない。
104
+ *
105
+ * **所有の宣言は裏取りと対で広げる。** `owns`だけ足すとその資源は`sensor_unbound`になり、compileは
106
+ * 非dispatchable(`BOUNDARY_UNKNOWN`)へ落ちる。そこでは競合が投影されないので、翻訳したのに
107
+ * 競合辺が立たず、変換の便益が測れない。裏取りに使えるqueryがrun のquery setに無ければ、
108
+ * 広げずに理由を返す——証明できない宣言を作らない。
109
+ */
110
+ export function reconcileWitnessToObservation({
111
+ manualWitness, contestedPath, todoIds, sensorQuerySet = null, observedAffectedTests = null,
112
+ } = {}) {
113
+ const reconciled = {};
114
+ const widened = [];
115
+ const reject = (reason) => ({ manualWitness: null, widened: [], reasons: [reason] });
116
+ // 係争pathを覆えるqueryを、run のquery setから拾う。無ければ翻訳しない。
117
+ //
118
+ // `affected`だけを見る。path所有を裏取りするのはこのoperationであり、構造query(query/callers/
119
+ // callees/impact)はsymbolを的にする。構造queryがたまたま同じ文字列をtargetに持つからといって
120
+ // 所有の裏取りへ流用すると、束縛の意味が変わる。
121
+ const covering = (sensorQuerySet?.queries ?? [])
122
+ .filter((query) => query.operation === 'affected' && query.target === contestedPath)
123
+ .map((query) => query.id)
124
+ .sort(compareText);
125
+
126
+ for (const todoId of todoIds) {
127
+ const witness = manualWitness?.[todoId];
128
+ if (!plainObject(witness)) return reject(`witness_absent:${todoId}`);
129
+ const next = structuredClone(witness);
130
+ const ownsPath = (next.owns ?? [])
131
+ .some((own) => own.kind === 'path' && own.target === contestedPath);
132
+ const writesPath = (next.writes ?? []).includes(contestedPath);
133
+ const boundPath = (next.sensor_provenance?.queries ?? [])
134
+ .some((entry) => (entry.expect?.kind === 'affected' || entry.expect?.kind === 'path')
135
+ && entry.expect?.path === contestedPath);
136
+ // 既に所有と書き込みを宣言しているなら、観測は予測の内側にある。合わせるものが無いので
137
+ // 触らない——裏取りが足りているかどうかは、その宣言を書いた側の問題であり、compileが見る。
138
+ // 翻訳が手を入れてよいのは、観測が予測を超えた分だけである。
139
+ if (ownsPath && writesPath) {
140
+ reconciled[todoId] = next;
141
+ continue;
142
+ }
143
+ if (!boundPath) {
144
+ if (covering.length === 0) return reject(`observation_unbacked:${todoId}:${contestedPath}`);
145
+ // 同じ資源は同じqueryで裏取りする。TODOごとに別のqueryを選ぶと、front-endが被覆の
146
+ // 曖昧さとして弾く。
147
+ if (covering.length > 1) return reject(`observation_binding_ambiguous:${contestedPath}`);
148
+ // 観測できていないaffectedを推測で埋めない。
149
+ if (!Array.isArray(observedAffectedTests)) {
150
+ return reject(`observation_affected_unread:${contestedPath}`);
151
+ }
152
+ }
153
+ // 所有・書き込み・裏取りをまとめて足す。観測されたのは「このpathへの書き込み」であり、
154
+ // 宣言の一部だけを合わせると、宣言が実態からずれたまま次の判定の前提になる。`creates`は
155
+ // 付けない——観測できたのはpathが既に在るからである。
156
+ if (!ownsPath) next.owns = [...next.owns, { kind: 'path', target: contestedPath }]
157
+ .sort((left, right) => compareText(`${left.kind}\0${left.target}`, `${right.kind}\0${right.target}`));
158
+ if (!writesPath) next.writes = [...next.writes, contestedPath].sort(compareText);
159
+ if (!boundPath) {
160
+ next.sensor_provenance = {
161
+ ...next.sensor_provenance,
162
+ queries: [...next.sensor_provenance.queries,
163
+ { query_id: covering[0], expect: { kind: 'affected', path: contestedPath } }],
164
+ };
165
+ // 面を1つ引き受けたら、その面のaffected testも引き受ける。宣言と観測はTODO単位で
166
+ // exact一致を要求されるので、片方だけ広げるとdriftになる。
167
+ next.affected_tests = [...new Set([...next.affected_tests, ...observedAffectedTests])]
168
+ .sort(compareText);
169
+ }
170
+ reconciled[todoId] = next;
171
+ widened.push({
172
+ todo_id: todoId,
173
+ resource: { kind: 'path', target: contestedPath },
174
+ fields: [
175
+ ...(ownsPath ? [] : ['owns']),
176
+ ...(writesPath ? [] : ['writes']),
177
+ ...(boundPath ? [] : ['affected_tests', 'sensor_provenance']),
178
+ ].sort(compareText),
179
+ });
180
+ }
181
+ return { manualWitness: reconciled, widened, reasons: [] };
182
+ }
183
+
80
184
  /**
81
185
  * 実行時witnessへconcern anchorを足してtodo witness setにする。
82
186
  *
83
187
  * 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
84
188
  * 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
85
189
  * 契約に書けるものではないからである。
190
+ *
191
+ * anchorを足す前に宣言を観測へ合わせる(`reconcileWitnessToObservation`)。この順序でないと、
192
+ * 係争資源を所有していないTODOのanchorが必ず不正になる。
86
193
  */
87
- export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath, executors }) {
194
+ export function buildRuntimeSeamWitnessSet({
195
+ request, declaration, contestedPath, executors, observedAffectedTests = null,
196
+ }) {
88
197
  const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
198
+ const translated = reconcileWitnessToObservation({
199
+ manualWitness: request.manual_witness, contestedPath, todoIds,
200
+ sensorQuerySet: request.sensor_query_set, observedAffectedTests,
201
+ });
202
+ if (translated.manualWitness === null) {
203
+ return { witnessSet: null, widened: [], reasons: translated.reasons };
204
+ }
89
205
  const manual = {};
90
206
  for (const todoId of todoIds) {
91
- const witness = request.manual_witness?.[todoId];
92
- if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
93
207
  manual[todoId] = {
94
- ...structuredClone(witness),
208
+ ...translated.manualWitness[todoId],
95
209
  concern_anchors: [{
96
210
  within: { kind: 'path', target: contestedPath },
97
211
  symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
@@ -108,7 +222,7 @@ export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath
108
222
  witness_set_digest: '',
109
223
  };
110
224
  witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
111
- return { witnessSet, reasons: [] };
225
+ return { witnessSet, widened: translated.widened, reasons: [] };
112
226
  }
113
227
 
114
228
  function syntheticTodoPlan(todoIds) {
@@ -137,7 +251,7 @@ export async function resolveRuntimeSeam({
137
251
  const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
138
252
 
139
253
  if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
140
- return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
254
+ return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null, widened: [] };
141
255
  }
142
256
  const findingTodoIds = [...finding.todo_ids].sort(compareText);
143
257
  if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
@@ -145,30 +259,41 @@ export async function resolveRuntimeSeam({
145
259
  lane: 'intentional_serial',
146
260
  reasons: ['declared_todos_differ_from_finding'],
147
261
  split: null,
262
+ widened: [],
148
263
  };
149
264
  }
150
265
 
266
+ // sensorは1回だけ引く。翻訳(宣言を観測へ合わせる)とcompileは同じ観測の上で行う——
267
+ // 別々に引くと、翻訳が見た実態とcompileが見た実態がずれうる。
268
+ const sensorEvidence = await collectWitnessSensorEvidence({
269
+ cwd: repoRoot, witnessSet: { sensor_query_set: request.sensor_query_set },
270
+ });
151
271
  const built = buildRuntimeSeamWitnessSet({
152
272
  request, declaration, contestedPath: finding.path,
153
273
  executors: request.capacity.executors,
274
+ observedAffectedTests: affectedTestsFromEvidence({
275
+ sensorEvidence, querySet: request.sensor_query_set, path: finding.path,
276
+ }),
154
277
  });
155
278
  if (built.witnessSet === null) {
156
- return { lane: 'intentional_serial', reasons: built.reasons, split: null };
279
+ return { lane: 'intentional_serial', reasons: built.reasons, split: null, widened: [] };
157
280
  }
158
281
  const witnessSet = built.witnessSet;
159
282
  const plan = syntheticTodoPlan(todoIds);
160
283
 
161
- // 観測したaffected testsだけを検証に使う。宣言から発明しない。
284
+ // 観測したaffected testsだけを検証に使う。宣言から発明しない。翻訳後の宣言から採る——
285
+ // 所有が広がったTODOは、その面のtestも自分のaffectedとして引き受けている。
162
286
  const affectedTests = [...new Set(todoIds
163
- .flatMap((todoId) => request.manual_witness[todoId].affected_tests))].sort(compareText);
287
+ .flatMap((todoId) => witnessSet.manual_witness[todoId].affected_tests))].sort(compareText);
164
288
 
165
289
  const baseArtifact = compileTodoIndependence({
166
- witnessSet, plan, baseSha, compiledAt,
167
- sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
290
+ witnessSet, plan, baseSha, compiledAt, sensorEvidence,
168
291
  });
169
292
 
170
293
  const pathNames = { ...declaration.path_names };
171
- return resolveRuntimeSeamTreatment({
294
+ // 翻訳で広げた宣言は決着へ載せる。どの宣言の上で五条件を判定したかが残らないと、
295
+ // 「変換して通った」という記録が何についてのものか確定しない。
296
+ const resolved = await resolveRuntimeSeamTreatment({
172
297
  finding,
173
298
  witnessSet,
174
299
  pathNames,
@@ -176,6 +301,8 @@ export async function resolveRuntimeSeam({
176
301
  manifestDigest: baseArtifact.result_digest,
177
302
  affectedTests,
178
303
  taskMigrationDigest: declaration.task_migration_digest,
304
+ // storeへ記録されたfindingのidで縛る。再計画側はこのidでfinding recordを読む。
305
+ recordedFindingDigest: findingRecord.finding_digest,
179
306
  commitTransform: async ({ files, candidateId }) => commitSeamTransform({
180
307
  repoRoot, baseSha, files, candidateId,
181
308
  }),
@@ -205,6 +332,7 @@ export async function resolveRuntimeSeam({
205
332
  return { ...applied, candidate: applied.candidate ?? null };
206
333
  },
207
334
  });
335
+ return { ...resolved, widened: built.widened };
208
336
  }
209
337
 
210
338
  /** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
@@ -215,6 +343,21 @@ export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
215
343
  finding_digest: findingDigest,
216
344
  lane: resolved.lane,
217
345
  reasons: [...resolved.reasons].sort(compareText),
346
+ // 確実の門(sc-012)。拒否理由を「宣言を直せば機械で通る」「AIが変換すべき」へ分類し、
347
+ // 次に誰が動くべきかを事実として返す。可否は決めない。
348
+ gate: explainSeamGate(resolved.reasons ?? []),
349
+ // 判定の前に宣言をどれだけ観測へ合わせたか。空配列は「予測が実態を覆っていた」という
350
+ // 意味であり、翻訳しなかったことと区別できる。
351
+ reconciled: [...(resolved.widened ?? [])]
352
+ .map((entry) => ({
353
+ todo_id: entry.todo_id,
354
+ resource: { kind: entry.resource.kind, target: entry.resource.target },
355
+ fields: [...entry.fields].sort(compareText),
356
+ }))
357
+ .sort((left, right) => compareText(
358
+ `${left.todo_id}\0${left.resource.kind}\0${left.resource.target}`,
359
+ `${right.todo_id}\0${right.resource.kind}\0${right.resource.target}`,
360
+ )),
218
361
  split: resolved.split ?? null,
219
362
  successor_base_sha: resolved.successor_base_sha ?? null,
220
363
  successor_base_ref: resolved.successor_base_ref ?? null,
@@ -99,7 +99,7 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
99
99
  const {
100
100
  finding, witnessSet, pathNames = {}, predeclaredTreatments = [],
101
101
  applyConflict, commitTransform, baseSha, manifestDigest, affectedTests = [],
102
- taskMigrationDigest,
102
+ taskMigrationDigest, recordedFindingDigest = null,
103
103
  } = options;
104
104
 
105
105
  if (finding?.kind === 'observed_write_conflict' && typeof finding.path === 'string') {
@@ -112,6 +112,7 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
112
112
 
113
113
  const { conflict, reasons } = seamConflictFromFinding({
114
114
  finding, witnessSet, pathNames, affectedTests, baseSha, manifestDigest,
115
+ recordedFindingDigest,
115
116
  });
116
117
  if (conflict === null) {
117
118
  return { lane: 'intentional_serial', treatment: null, split: null, reasons };