@quolu/lattice 0.52.2 → 0.52.4

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 (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +699 -697
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +3143 -3141
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +4360 -4322
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. package/src/witness-scaffold.mjs +180 -180
@@ -1,779 +1,779 @@
1
- import { digestArtifact } from './artifact-contracts.mjs';
2
- import { digestRunEvent } from './runtime-event-store.mjs';
3
- import { projectRuntimeState } from './runtime-projection.mjs';
4
- import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
5
- import {
6
- RUN_EVENT_KINDS,
7
- computeContextContentDigest,
8
- selfDigest,
9
- validateExecutorPacket,
10
- validateExecutorReceipt,
11
- validateRunEvent,
12
- validateRuntimePlan,
13
- verifyRuntimePlanBinding,
14
- } from './runtime-contracts.mjs';
15
-
16
- /**
17
- * RC3-E ready-frontier runtime engine(ADR 0044 Decision 3・4・7.4・9、plan RC3-E)。
18
- *
19
- * event chainを生成するproducer。dispatch選択とreceipt裁定は
20
- * `runtime-decision-verifier.mjs`と**独立の実装**で持ち、verifierは保存bytesだけから
21
- * 全decisionを再計算する(producer非依存検証の二重実装が意図。同一コードの
22
- * 自己照合にしない)。
23
- *
24
- * - minimum waveを同期barrierとして使わず、eventごとにhard predecessor・
25
- * running conflict・実capacityからready frontierを再評価する(Decision 4)。
26
- * - dispatchは必ずexecutor_packet.v1を伴い、dispatch記録(executor_handle・
27
- * worktree_id・packet_digest)がreceipt帰属の唯一の基準になる(Decision 7.4/9.3)。
28
- * - 稼働中TODOの重複dispatchはtyped rejectする。timeoutはfailureでなくunknownとして
29
- * 扱い、同一handleの回収だけを許す(Decision 9.4)。
30
- * - engineはevent配列を唯一のstate sourceにし、可変summaryを持たない(Decision 3.3)。
31
- */
32
-
33
- const ENGINE_ACTOR = 'lattice-runtime';
34
- /**
35
- * workerへ配る禁止操作。
36
- *
37
- * `commit`は含めない。自分の隔離worktreeでdetached HEADへ進めるcommitは、canonical branchを
38
- * 動かさず外部へ効果を出さない一方で、進行中の成果を耐久化し、diff観測が生み出した木そのものを
39
- * 縛れるようにする(ADR 0139)。
40
- *
41
- * 禁止のままにするのは、HEADをbaseの子孫から外す操作と、外部へ効果を出す操作である。
42
- * 前者は観測の前提を壊し、後者は承認なしに行わないという公開契約に当たる。
43
- */
44
- const FORBIDDEN_OPERATIONS = Object.freeze([
45
- 'push', 'branch', 'merge', 'rebase', 'reset', 'stash',
46
- ]);
47
-
48
- function fail(reason) {
49
- throw new TypeError(`runtime engine契約違反: ${reason}`);
50
- }
51
-
52
- function plainRecord(value) {
53
- return value !== null
54
- && typeof value === 'object'
55
- && !Array.isArray(value)
56
- && Object.getPrototypeOf(value) === Object.prototype;
57
- }
58
-
59
- function exactRecord(value, keys) {
60
- if (!plainRecord(value)) return false;
61
- const actual = Object.keys(value).sort();
62
- const expected = [...keys].sort();
63
- return actual.length === expected.length
64
- && actual.every((key, index) => key === expected[index]);
65
- }
66
-
67
- function requireTimestamp(value) {
68
- if (!isCanonicalUtcTimestamp(value)) {
69
- fail(`recordedAtがcanonical UTC timestampではない: ${String(value)}`);
70
- }
71
- return value;
72
- }
73
-
74
- function sortedText(values) {
75
- return [...values].sort();
76
- }
77
-
78
- /**
79
- * 直前event prefixへchainされた新しいrun eventを構築する(append専用)。
80
- */
81
- export function buildNextRunEvent(options = {}) {
82
- if (!exactRecord(options, ['events', 'runId', 'kind', 'planEpoch', 'subject', 'payload', 'recordedAt'])) {
83
- fail('buildNextRunEvent optionsがexact shapeでない');
84
- }
85
- const { events, runId, kind, planEpoch, subject, payload, recordedAt } = options;
86
- if (!Array.isArray(events)) fail('eventsがarrayではない');
87
- if (!RUN_EVENT_KINDS.includes(kind)) fail(`未知のevent kind: ${String(kind)}`);
88
- const previous = events.length === 0 ? null : events[events.length - 1];
89
- // genesisはrun_initializedだけを許し、追記先prefixのrun同一性と末尾eventの
90
- // 自己digest整合を検査する(別runへの追記・改竄済みprefixへの追記を塞ぐ)。
91
- if (previous === null) {
92
- if (kind !== 'run_initialized') fail(`genesis eventはrun_initializedでなければならない: ${kind}`);
93
- } else {
94
- if (previous.run_id !== runId) fail(`別runのprefixへ追記できない: ${String(previous.run_id)}`);
95
- if (previous.event_digest !== digestRunEvent(previous)) {
96
- fail('prefix末尾eventのdigestが自己整合しない(改竄または破損)');
97
- }
98
- }
99
- const event = {
100
- schema: 'lattice.run_event.v1',
101
- run_id: runId,
102
- sequence: previous === null ? 0 : previous.sequence + 1,
103
- previous_digest: previous === null ? null : previous.event_digest,
104
- kind,
105
- actor: ENGINE_ACTOR,
106
- plan_epoch: planEpoch,
107
- subject: structuredClone(subject),
108
- payload: structuredClone(payload),
109
- recorded_at: requireTimestamp(recordedAt),
110
- };
111
- event.event_digest = digestRunEvent(event);
112
- if (!validateRunEvent(event)) fail(`生成eventがrun_event.v1 contractを満たさない: ${kind}`);
113
- return event;
114
- }
115
-
116
- /**
117
- * run genesis: run_initialized+plan_compiledのevent prefixを作る。
118
- */
119
- export function initializeRunEvents(options = {}) {
120
- if (!exactRecord(options, ['runId', 'request', 'plan', 'manifests', 'recordedAt'])) {
121
- fail('initializeRunEvents optionsがexact shapeでない');
122
- }
123
- const { runId, request, plan, manifests, recordedAt } = options;
124
- if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
125
- if (!verifyRuntimePlanBinding({ plan, request })) fail('planがrequestへbindできない');
126
- const events = [];
127
- events.push(buildNextRunEvent({
128
- events,
129
- runId,
130
- kind: 'run_initialized',
131
- planEpoch: 0,
132
- subject: { kind: 'run_request', ref: 'request.json' },
133
- payload: { request_digest: request.request_digest },
134
- recordedAt,
135
- }));
136
- events.push(buildNextRunEvent({
137
- events,
138
- runId,
139
- kind: 'plan_compiled',
140
- planEpoch: plan.plan_epoch,
141
- subject: { kind: 'runtime_plan', ref: plan.plan_ref },
142
- payload: {
143
- plan_digest: plan.plan_digest,
144
- manifest_digests: { ...plan.manifest_digests },
145
- manifests_digest: digestArtifact(manifests),
146
- },
147
- recordedAt,
148
- }));
149
- return events;
150
- }
151
-
152
- /**
153
- * boundary manifestからexecutor_packet.v1を構築する(Decision 9.3)。
154
- */
155
- export function buildExecutorPackets(options = {}) {
156
- if (!exactRecord(options, ['plan', 'manifests'])) {
157
- fail('buildExecutorPackets optionsがexact shapeでない');
158
- }
159
- const { plan, manifests } = options;
160
- if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
161
- const packets = {};
162
- for (const node of plan.nodes) {
163
- const manifest = manifests[node.todo_id];
164
- if (!plainRecord(manifest) || manifest.manifest_digest !== plan.manifest_digests[node.todo_id]) {
165
- fail(`manifestがplanのdigestと一致しない: ${node.todo_id}`);
166
- }
167
- const content = {
168
- todo_id: node.todo_id,
169
- task_ref: `${plan.plan_ref}-task-${node.todo_id}`,
170
- scope: { writes: [...manifest.writes] },
171
- base_sha: plan.base_sha,
172
- verifier_refs: manifest.affected_tests.map((test) => `node --test ${test}`),
173
- forbidden_operations: [...FORBIDDEN_OPERATIONS],
174
- };
175
- const packet = {
176
- schema: 'lattice.executor_packet.v1',
177
- packet_id: `${plan.plan_ref}-packet-${node.todo_id}`,
178
- ...content,
179
- plan_ref: plan.plan_ref,
180
- plan_epoch: plan.plan_epoch,
181
- context_content_digest: computeContextContentDigest(content),
182
- };
183
- packet.packet_digest = selfDigest(packet, 'packet_digest');
184
- if (!validateExecutorPacket(packet)) fail(`生成packetがexecutor_packet.v1 contractを満たさない: ${node.todo_id}`);
185
- packets[node.todo_id] = packet;
186
- }
187
- return packets;
188
- }
189
-
190
- /**
191
- * engine側のready frontier選択(verifierと独立の実装)。
192
- * hard predecessor充足・running/held除外・conflict pairの同時実行回避・
193
- * 実capacityの空きだけを根拠に、辞書順の貪欲選択で決める。
194
- */
195
- function selectDispatchable(plan, state, events) {
196
- if (state.freeze !== null || state.closed) return [];
197
- const accepted = new Set(state.accepted);
198
- const running = new Set(state.running);
199
- // dispatch済み・terminal済み・heldの除外は現plan epochへscopeする
200
- // (旧epoch分はcontext失効後にredispatch可能。RC3-G)。
201
- const held = new Set();
202
- const terminal = new Set();
203
- const dispatched = new Set();
204
- for (const event of events) {
205
- if (event.plan_epoch !== plan.plan_epoch) continue;
206
- if (event.kind === 'hold_decided') {
207
- for (const todoId of event.payload?.hold_set ?? []) held.add(todoId);
208
- }
209
- if (event.subject?.kind !== 'todo') continue;
210
- if (event.kind === 'executor_dispatched') dispatched.add(event.subject.ref);
211
- if (event.kind === 'executor_terminal') terminal.add(event.subject.ref);
212
- }
213
- const free = plan.capacity.executors - running.size;
214
- if (free < 1) return [];
215
-
216
- const chosen = [];
217
- for (const node of sortedText(plan.nodes.map((entry) => entry.todo_id))) {
218
- if (chosen.length >= free) break;
219
- if (accepted.has(node) || running.has(node) || held.has(node) || terminal.has(node)) continue;
220
- if (dispatched.has(node)) continue; // 同一epoch内の再dispatchを塞ぐ
221
- const predecessorsMet = plan.precedence.every((edge) => (
222
- edge.to_todo_id !== node || accepted.has(edge.from_todo_id)
223
- ));
224
- if (!predecessorsMet) continue;
225
- const conflictFree = plan.conflicts.every((conflict) => {
226
- if (!conflict.todo_ids.includes(node)) return true;
227
- return conflict.todo_ids.every((member) => (
228
- member === node || (!running.has(member) && !chosen.includes(member))
229
- ));
230
- });
231
- if (conflictFree) chosen.push(node);
232
- }
233
- return chosen;
234
- }
235
-
236
- /**
237
- * ready frontierを評価し、dispatch_decided eventと、選択TODOごとの
238
- * executor_dispatched eventを追記する。dispatchはadapterへ委ね、opaque handleだけを
239
- * event payloadへ保存する。返り値は追記後のevents(入力配列は変更しない)。
240
- */
241
- export async function dispatchReadyFrontier(options = {}) {
242
- if (!exactRecord(options, ['runId', 'plan', 'events', 'packets', 'manifests', 'adapter', 'recordedAt'])) {
243
- fail('dispatchReadyFrontier optionsがexact shapeでない');
244
- }
245
- const { runId, plan, events, packets, manifests, adapter, recordedAt } = options;
246
- if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
247
- if (typeof adapter?.dispatch !== 'function') fail('adapterがdispatchを実装していない');
248
-
249
- const state = projectRuntimeState({ events });
250
- const dispatchable = selectDispatchable(plan, state, events);
251
- let next = [...events];
252
- next.push(buildNextRunEvent({
253
- events: next,
254
- runId,
255
- kind: 'dispatch_decided',
256
- planEpoch: plan.plan_epoch,
257
- subject: { kind: 'runtime_plan', ref: plan.plan_ref },
258
- payload: {
259
- dispatchable: [...dispatchable],
260
- running: [...state.running],
261
- accepted: [...state.accepted],
262
- capacity: plan.capacity.executors,
263
- },
264
- recordedAt,
265
- }));
266
-
267
- const dispatchedNow = [];
268
- for (const todoId of dispatchable) {
269
- const packet = packets[todoId];
270
- if (!validateExecutorPacket(packet) || packet.todo_id !== todoId) {
271
- fail(`dispatchにはexecutor_packet.v1が必要: ${todoId}`);
272
- }
273
- // packetはactive planへ帰属しなければならない。自己整合しただけの
274
- // 別plan/別scope packetのdispatchを塞ぐ(review P1採用)。
275
- const manifest = manifests[todoId];
276
- if (packet.plan_ref !== plan.plan_ref
277
- || packet.plan_epoch !== plan.plan_epoch
278
- || packet.base_sha !== plan.base_sha
279
- || packet.packet_id !== `${plan.plan_ref}-packet-${todoId}`
280
- || packet.task_ref !== `${plan.plan_ref}-task-${todoId}`
281
- || !plainRecord(manifest)
282
- || manifest.manifest_digest !== plan.manifest_digests[todoId]
283
- || JSON.stringify(packet.scope.writes) !== JSON.stringify(manifest.writes)) {
284
- fail(`packetがactive planへ帰属しない: ${todoId}`);
285
- }
286
- // adapter失敗時も、成功済みdispatchの証拠を失わずtyped failureとして返す
287
- // (起動済みexecutorをevent store外へ孤立させない。review P1採用)。
288
- let dispatchResult;
289
- try {
290
- dispatchResult = await adapter.dispatch({ packet });
291
- } catch (error) {
292
- return {
293
- events: next,
294
- dispatched: dispatchedNow,
295
- failure: { todo_id: todoId, message: String(error?.message ?? error) },
296
- };
297
- }
298
- if (!plainRecord(dispatchResult)
299
- || typeof dispatchResult.executor_handle !== 'string'
300
- || dispatchResult.executor_handle.length === 0
301
- || typeof dispatchResult.worktree_id !== 'string'
302
- || dispatchResult.worktree_id.length === 0) {
303
- fail(`adapter dispatchがopaque handle/worktreeを返さない: ${todoId}`);
304
- }
305
- const dispatchPayload = {
306
- executor_handle: dispatchResult.executor_handle,
307
- worktree_id: dispatchResult.worktree_id,
308
- packet_digest: packet.packet_digest,
309
- context_content_digest: packet.context_content_digest,
310
- };
311
- const managedFields = [
312
- 'write_lease_id',
313
- 'write_lease_digest',
314
- 'controller_registration_digest',
315
- 'controller_session_nonce_digest',
316
- 'direct_os_observation_binding',
317
- ];
318
- if (managedFields.some((field) => Object.hasOwn(dispatchResult, field))) {
319
- if (!managedFields.every((field) => Object.hasOwn(dispatchResult, field))) {
320
- fail(`managed adapter dispatch bindingが不足する: ${todoId}`);
321
- }
322
- Object.assign(dispatchPayload, Object.fromEntries(
323
- managedFields.map((field) => [field, structuredClone(dispatchResult[field])]),
324
- ));
325
- }
326
- next.push(buildNextRunEvent({
327
- events: next,
328
- runId,
329
- kind: 'executor_dispatched',
330
- planEpoch: plan.plan_epoch,
331
- subject: { kind: 'todo', ref: todoId },
332
- payload: dispatchPayload,
333
- recordedAt,
334
- }));
335
- dispatchedNow.push(todoId);
336
- }
337
- return { events: next, dispatched: dispatchedNow, failure: null };
338
- }
339
-
340
- /**
341
- * 稼働executorをadapter経由で観測し、checkpoint/terminalをeventへ写す。
342
- * timeout(unknown)はfailureへ丸めず、eventを追加しない(同一handleでの再観測だけを許す)。
343
- */
344
- export async function observeExecutor(options = {}) {
345
- if (!exactRecord(options, ['runId', 'plan', 'events', 'adapter', 'todoId', 'recordedAt'])) {
346
- fail('observeExecutor optionsがexact shapeでない');
347
- }
348
- const { runId, plan, events, adapter, todoId, recordedAt } = options;
349
- if (typeof adapter?.observe !== 'function') fail('adapterがobserveを実装していない');
350
- const state = projectRuntimeState({ events });
351
- const dispatch = state.dispatches[todoId];
352
- if (dispatch === undefined) fail(`未dispatchのTODOを観測できない: ${todoId}`);
353
- // terminal報告済みexecutorの再観測は二重receipt(同一receipt再送での二重受理)
354
- // の入口になるためfail closed(review P1採用)。guardは現plan epochへscopeする
355
- // (旧epochでcontext_invalidated終端されたTODOのredispatch後観測を塞がない)。
356
- const terminalInEpoch = events.some((event) => (
357
- event.kind === 'executor_terminal'
358
- && event.plan_epoch === plan.plan_epoch
359
- && event.subject?.kind === 'todo'
360
- && event.subject.ref === todoId
361
- ));
362
- if (terminalInEpoch) fail(`terminal報告済みTODOを再観測できない: ${todoId}`);
363
-
364
- const observation = await adapter.observe({ executor_handle: dispatch.payload.executor_handle });
365
- if (!plainRecord(observation) || typeof observation.state !== 'string') {
366
- fail(`adapter observationが不正: ${todoId}`);
367
- }
368
-
369
- let next = [...events];
370
- if (observation.state === 'unknown') {
371
- return { events: next, observation: { state: 'unknown' } };
372
- }
373
- if (observation.state === 'checkpoint_ready') {
374
- if (!plainRecord(observation.checkpoint)
375
- || typeof observation.checkpoint.checkpoint_digest !== 'string'
376
- || !/^[0-9a-f]{64}$/.test(observation.checkpoint.checkpoint_digest)) {
377
- fail(`checkpoint payloadにはcheckpoint_digestが必要: ${todoId}`);
378
- }
379
- next.push(buildNextRunEvent({
380
- events: next,
381
- runId,
382
- kind: 'checkpoint_observed',
383
- planEpoch: plan.plan_epoch,
384
- subject: { kind: 'todo', ref: todoId },
385
- payload: structuredClone(observation.checkpoint),
386
- recordedAt,
387
- }));
388
- return { events: next, observation: { state: 'checkpoint_ready' } };
389
- }
390
- if (observation.state === 'terminal') {
391
- if (!validateExecutorReceipt(observation.receipt)) {
392
- fail(`terminal receiptがexecutor_receipt.v1 contractを満たさない: ${todoId}`);
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
- }
410
- next.push(buildNextRunEvent({
411
- events: next,
412
- runId,
413
- kind: 'receipt_recorded',
414
- planEpoch: observation.receipt.plan_epoch,
415
- subject: { kind: 'todo', ref: todoId },
416
- payload: structuredClone(observation.receipt),
417
- recordedAt,
418
- }));
419
- // cleanup失敗は成功へ丸めず、残存pathと回収条件をterminal eventへ保存する
420
- // (plan RC3-F)。cleanup情報のないadapterは省略可。
421
- const terminalPayload = {
422
- executor_handle: dispatch.payload.executor_handle,
423
- terminal_state: 'reported',
424
- };
425
- if (plainRecord(observation.cleanup)) {
426
- terminalPayload.cleanup = structuredClone(observation.cleanup);
427
- }
428
- next.push(buildNextRunEvent({
429
- events: next,
430
- runId,
431
- kind: 'executor_terminal',
432
- planEpoch: plan.plan_epoch,
433
- subject: { kind: 'todo', ref: todoId },
434
- payload: terminalPayload,
435
- recordedAt,
436
- }));
437
- return { events: next, observation: { state: 'terminal' } };
438
- }
439
- if (observation.state === 'hold_requested') {
440
- // executorからの競合通報はconflict_found eventとして保存し、直後にevent intakeを
441
- // freezeする(競合発見時にdispatchを続けるfail-open経路を塞ぐ。review P1採用)。
442
- // affected closure計算とhold裁定・resumeはRC3-Gのhold契約が所有する。
443
- if (!plainRecord(observation.finding)
444
- || typeof observation.finding.kind !== 'string'
445
- || observation.finding.kind.length === 0) {
446
- fail(`hold requestにはfinding payloadが必要: ${todoId}`);
447
- }
448
- next.push(buildNextRunEvent({
449
- events: next,
450
- runId,
451
- kind: 'conflict_found',
452
- planEpoch: plan.plan_epoch,
453
- subject: { kind: 'todo', ref: todoId },
454
- payload: {
455
- ...structuredClone(observation.finding),
456
- reported_by: dispatch.payload.executor_handle,
457
- },
458
- recordedAt,
459
- }));
460
- next.push(buildNextRunEvent({
461
- events: next,
462
- runId,
463
- kind: 'intake_frozen',
464
- planEpoch: plan.plan_epoch,
465
- subject: { kind: 'runtime_plan', ref: plan.plan_ref },
466
- payload: {
467
- frozen_prefix_digest: digestArtifact(next.map((event) => event.event_digest)),
468
- reason_kind: observation.finding.kind,
469
- },
470
- recordedAt,
471
- }));
472
- return { events: next, observation: { state: 'hold_requested' } };
473
- }
474
- if (observation.state === 'running') {
475
- return { events: next, observation: { state: 'running' } };
476
- }
477
- fail(`未知のadapter observation state: ${observation.state}`);
478
- return null;
479
- }
480
-
481
- /**
482
- * engine側のreceipt裁定(verifierと独立の実装。Decision 7.4の帰属規則)。
483
- * 帰属の基準はdispatch記録(handle・worktree・packet digest)であり、
484
- * executor自己申告を信用しない。裁定はreceipt_accepted/receipt_rejected eventへ保存する。
485
- */
486
- /**
487
- * 直近のcheckpoint観測をdeclared scope/他running TODOへcross-bindし、
488
- * findingがあればconflict_found+intake_frozenを追記する(RC3-F検出、裁定はRC3-G)。
489
- */
490
- export function classifyCheckpointObservation(options = {}) {
491
- if (!exactRecord(options, ['runId', 'plan', 'events', 'packets', 'manifests', 'todoId', 'detect', 'recordedAt'])) {
492
- fail('classifyCheckpointObservation optionsがexact shapeでない');
493
- }
494
- const { runId, plan, events, packets, manifests, todoId, detect, recordedAt } = options;
495
- if (typeof detect !== 'function') fail('detect関数が必要');
496
- const state = projectRuntimeState({ events });
497
- const checkpoints = state.checkpoints.filter((entry) => entry.todo_id === todoId);
498
- if (checkpoints.length === 0) fail(`checkpoint未観測のTODOを分類できない: ${todoId}`);
499
- const checkpoint = checkpoints[checkpoints.length - 1].payload;
500
- const { findings: detectedFindings } = detect({
501
- todoId,
502
- checkpoint,
503
- packets,
504
- manifests,
505
- runningTodoIds: state.running,
506
- });
507
- if (!Array.isArray(detectedFindings)) fail('detectがfindings配列を返さない');
508
- const observations = detectedFindings
509
- .filter((finding) => finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1)
510
- .map((finding) => ({ ...finding, kind: 'prediction_excess' }));
511
- const scopeExpanded = observations.length === 0 ? [] : (() => {
512
- const manifest = manifests[todoId];
513
- const predictedPaths = sortedText(new Set(manifest?.writes ?? []));
514
- const addedPaths = sortedText(new Set(observations.map((observation) => {
515
- const location = observation.path ?? observation.resource_id;
516
- if (typeof location !== 'string' || location.length === 0) {
517
- fail('prediction_excessにpathまたはresource_idがない');
518
- }
519
- return location;
520
- })));
521
- const currentPaths = new Set([...predictedPaths, ...addedPaths]);
522
- const incoming = (plan.precedence ?? [])
523
- .filter((edge) => edge.to_todo_id === todoId)
524
- .length;
525
- return [{
526
- task_id: todoId,
527
- compared_witness_digest: null,
528
- first_seen_path_count: predictedPaths.length,
529
- path_count: currentPaths.size,
530
- added_paths: addedPaths,
531
- removed_paths: [],
532
- growth_events: 1,
533
- gate_shape: incoming >= 2,
534
- }];
535
- })();
536
- const findings = detectedFindings.filter((finding) => !(
537
- finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1
538
- ));
539
- // 再分類のidempotence: path/resourceのどちらであっても、同じfindingだけ再記録しない。
540
- const recordedKeys = new Set(state.conflicts.map((conflict) => (
541
- `${conflict.kind}|${[...(conflict.todo_ids ?? [])].sort().join(',')}|${conflict.path ?? ''}|${conflict.resource_id ?? ''}`
542
- )));
543
- const freshFindings = findings.filter((finding) => !recordedKeys.has(
544
- `${finding.kind}|${[...(finding.todo_ids ?? [])].sort().join(',')}|${finding.path ?? ''}|${finding.resource_id ?? ''}`,
545
- ));
546
- let next = [...events];
547
- for (const finding of freshFindings) {
548
- next.push(buildNextRunEvent({
549
- events: next,
550
- runId,
551
- kind: 'conflict_found',
552
- planEpoch: plan.plan_epoch,
553
- subject: { kind: 'todo', ref: todoId },
554
- payload: structuredClone(finding),
555
- recordedAt,
556
- }));
557
- }
558
- if (freshFindings.length > 0 && state.freeze === null) {
559
- next.push(buildNextRunEvent({
560
- events: next,
561
- runId,
562
- kind: 'intake_frozen',
563
- planEpoch: plan.plan_epoch,
564
- subject: { kind: 'runtime_plan', ref: plan.plan_ref },
565
- payload: {
566
- frozen_prefix_digest: digestArtifact(next.map((event) => event.event_digest)),
567
- reason_kind: findings[0].kind,
568
- },
569
- recordedAt,
570
- }));
571
- }
572
- return {
573
- events: next,
574
- findings: freshFindings,
575
- observations,
576
- scope_expanded: scopeExpanded,
577
- };
578
- }
579
-
580
- const RECEIPT_BINDING_FIELDS = Object.freeze([
581
- 'executor_handle',
582
- 'worktree_id',
583
- 'base_sha',
584
- 'packet_digest',
585
- 'checkpoint_digest',
586
- ]);
587
-
588
- function witnessProvenReceiptBinding(state, receipt) {
589
- const witnessRecord = state.witnesses[receipt.todo_id];
590
- const witness = witnessRecord?.payload?.witness;
591
- if (witness === null || witness === undefined) return false;
592
- if (witness.todo_id !== receipt.todo_id) return false;
593
- if (typeof witnessRecord.payload.witness_digest !== 'string'
594
- || witnessRecord.payload.witness_digest !== witness.witness_digest
595
- || witness.witness_digest !== selfDigest(witness, 'witness_digest')) {
596
- return false;
597
- }
598
- return Array.isArray(witness.receipt_bindings) && witness.receipt_bindings.some((binding) => (
599
- binding.receipt_id === receipt.receipt_id
600
- && binding.recorded_sequence === receipt.sequence
601
- && binding.within_frozen_prefix === true
602
- ));
603
- }
604
-
605
- function retainsOriginBinding(events, receipt, currentEpoch) {
606
- if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= currentEpoch) return false;
607
- for (let epoch = receipt.plan_epoch; epoch < currentEpoch; epoch += 1) {
608
- const witnessed = events.some((event) => event.sequence < receipt.sequence
609
- && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
610
- && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
611
- const continued = events.some((event) => event.sequence < receipt.sequence
612
- && event.kind === 'hold_decided' && event.plan_epoch === epoch
613
- && event.payload?.continue_set?.includes(receipt.todo_id));
614
- const recompiled = events.some((event) => event.sequence < receipt.sequence
615
- && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
616
- const invalidated = events.some((event) => event.sequence < receipt.sequence
617
- && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
618
- && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
619
- if (!witnessed || !continued || !recompiled || invalidated) return false;
620
- }
621
- return true;
622
- }
623
-
624
- export function adjudicatePendingReceipts(options = {}) {
625
- if (!exactRecord(options, ['runId', 'plan', 'events', 'recordedAt'])) {
626
- fail('adjudicatePendingReceipts optionsがexact shapeでない');
627
- }
628
- const { runId, plan, events, recordedAt } = options;
629
- if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
630
- const state = projectRuntimeState({ events });
631
- // freezeはresumeで消えない永続境界(Decision 7.4のstale判定基準は最初のfreeze)。
632
- // 境界は現plan epochで発生したfreezeへscopeする(vN+1 receiptをvNのfreezeで
633
- // 塞がない。verifierと同一規則)。
634
- const freezes = events
635
- .filter((event) => event.kind === 'intake_frozen' && event.plan_epoch === plan.plan_epoch)
636
- .map((event) => event.sequence)
637
- .sort((left, right) => left - right);
638
- const freezeBoundary = freezes.length === 0 ? null : freezes[0];
639
- const seenReceiptIds = new Set();
640
- for (const receipt of state.receipts) {
641
- if (receipt.accepted_sequence !== null || receipt.rejected_sequence !== null) {
642
- seenReceiptIds.add(receipt.receipt_id);
643
- }
644
- }
645
-
646
- let next = [...events];
647
- const decisions = [];
648
- for (const receipt of state.receipts) {
649
- if (receipt.accepted_sequence !== null || receipt.rejected_sequence !== null) continue;
650
- const payload = receipt.payload ?? {};
651
- const dispatch = state.dispatches[receipt.todo_id];
652
- const originBindingRetained = retainsOriginBinding(events, receipt, plan.plan_epoch);
653
- let rejection = null;
654
- if (seenReceiptIds.has(receipt.receipt_id)) {
655
- rejection = 'duplicate_receipt_id';
656
- } else if (RECEIPT_BINDING_FIELDS.some((field) => typeof payload[field] !== 'string')) {
657
- rejection = 'binding_missing';
658
- } else if (dispatch === undefined) {
659
- rejection = 'not_dispatched';
660
- } else if (payload.executor_handle !== dispatch.payload.executor_handle
661
- || payload.worktree_id !== dispatch.payload.worktree_id
662
- || payload.packet_digest !== dispatch.payload.packet_digest) {
663
- rejection = 'binding_mismatch';
664
- } else if (payload.base_sha !== plan.base_sha) {
665
- rejection = 'base_mismatch';
666
- } else if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
667
- rejection = 'epoch_mismatch';
668
- } else if ((() => {
669
- // dispatchが旧epochのTODOが現epochのreceiptを名乗る場合、epoch_rebound
670
- // event(rebindのsequenceがreceiptより前、new_plan_epoch一致)を必須にする。
671
- // rebindなしのepoch自称は旧contextの偽装であり受理しない(Decision 7.3/7.4)。
672
- const dispatchEvent = events.find((event) => (
673
- event.kind === 'executor_dispatched'
674
- && event.subject?.kind === 'todo'
675
- && event.subject.ref === receipt.todo_id
676
- && event.sequence === dispatch.sequence
677
- ));
678
- if (originBindingRetained || dispatchEvent === undefined
679
- || dispatchEvent.plan_epoch === plan.plan_epoch) return false;
680
- const rebound = state.rebinds[receipt.todo_id];
681
- return rebound === undefined
682
- || rebound.payload?.new_plan_epoch !== plan.plan_epoch
683
- || rebound.sequence >= receipt.sequence;
684
- })()) {
685
- rejection = 'unrebound_epoch';
686
- } else if ((() => {
687
- // receipt以前の最後の観測checkpointへのbindを要求(digestとobserved_diff。
688
- // executor自己申告をbinding証拠にしない。RC3-F)。bindingは同一dispatch
689
- // attemptへscopeする=最後のdispatch以降のcheckpointだけが対象
690
- // (redispatch後のreceiptを旧attemptのcheckpointで塞がない。RC3-G)。
691
- const attemptStart = dispatch.sequence;
692
- const observedCheckpoints = state.checkpoints.filter((entry) => (
693
- entry.todo_id === receipt.todo_id
694
- && entry.sequence > attemptStart
695
- && entry.sequence < receipt.sequence
696
- // supervisorが自分の判断で撮ったcheckpoint(I/O警報のprobe)は、executorの
697
- // 申告境界ではない。走行中の任意の一点なので、その後も書き続けたexecutorの
698
- // receiptと一致しないのが正常である。ここへ混ぜると、正当なreceiptが
699
- // checkpoint_mismatchで落ちる。証拠としては残り、findingの導出には使われる。
700
- && entry.payload?.observed_by !== 'supervisor_probe'
701
- ));
702
- if (observedCheckpoints.length === 0) return false;
703
- const last = observedCheckpoints[observedCheckpoints.length - 1].payload;
704
- if (typeof last?.checkpoint_digest === 'string'
705
- && payload.checkpoint_digest !== last.checkpoint_digest) {
706
- return true;
707
- }
708
- if (last?.diff?.entries !== undefined && Array.isArray(last.diff.entries)) {
709
- const expected = JSON.stringify(last.diff.entries.map(({ path: p, change }) => ({ path: p, change })));
710
- const reported = JSON.stringify((payload.observed_diff ?? []).map(({ path: p, change }) => ({ path: p, change })));
711
- if (expected !== reported) return true;
712
- }
713
- return false;
714
- })()) {
715
- rejection = 'checkpoint_mismatch';
716
- } else if (freezeBoundary !== null) {
717
- // freeze境界を跨いだreceiptはstale(Decision 7.4)。frozen prefix内のreceiptは
718
- // 実証済みcarry-over witnessのbindingがある場合だけ受理(Decision 7.5)。
719
- if (receipt.sequence > freezeBoundary) {
720
- rejection = 'post_freeze';
721
- } else if (!witnessProvenReceiptBinding(state, receipt)) {
722
- rejection = 'witness_unproven';
723
- }
724
- }
725
- seenReceiptIds.add(receipt.receipt_id);
726
- if (rejection === null) {
727
- next.push(buildNextRunEvent({
728
- events: next,
729
- runId,
730
- kind: 'receipt_accepted',
731
- planEpoch: plan.plan_epoch,
732
- subject: { kind: 'todo', ref: receipt.todo_id },
733
- payload: { receipt_id: receipt.receipt_id, checkpoint_digest: payload.checkpoint_digest ?? null },
734
- recordedAt,
735
- }));
736
- decisions.push({ receipt_id: receipt.receipt_id, decision: 'accepted' });
737
- } else {
738
- next.push(buildNextRunEvent({
739
- events: next,
740
- runId,
741
- kind: 'receipt_rejected',
742
- planEpoch: plan.plan_epoch,
743
- subject: { kind: 'todo', ref: receipt.todo_id },
744
- payload: { receipt_id: receipt.receipt_id, reason: 'stale_context', detail: rejection },
745
- recordedAt,
746
- }));
747
- decisions.push({ receipt_id: receipt.receipt_id, decision: 'rejected', detail: rejection });
748
- }
749
- }
750
- return { events: next, decisions };
751
- }
752
-
753
- /**
754
- * 全TODO terminalかつpending receipt 0の時だけrun_closedを追記する。
755
- */
756
- export function closeRunIfComplete(options = {}) {
757
- if (!exactRecord(options, ['runId', 'plan', 'events', 'recordedAt'])) {
758
- fail('closeRunIfComplete optionsがexact shapeでない');
759
- }
760
- const { runId, plan, events, recordedAt } = options;
761
- const state = projectRuntimeState({ events });
762
- const nodeIds = plan.nodes.map((node) => node.todo_id);
763
- const done = nodeIds.every((todoId) => state.accepted.includes(todoId));
764
- const pending = state.receipts.some((receipt) => (
765
- receipt.accepted_sequence === null && receipt.rejected_sequence === null
766
- ));
767
- if (!done || pending || state.closed) return { events, closed: state.closed };
768
- const next = [...events];
769
- next.push(buildNextRunEvent({
770
- events: next,
771
- runId,
772
- kind: 'run_closed',
773
- planEpoch: plan.plan_epoch,
774
- subject: { kind: 'runtime_plan', ref: plan.plan_ref },
775
- payload: { accepted: sortedText(state.accepted) },
776
- recordedAt,
777
- }));
778
- return { events: next, closed: true };
779
- }
1
+ import { digestArtifact } from './artifact-contracts.mjs';
2
+ import { digestRunEvent } from './runtime-event-store.mjs';
3
+ import { projectRuntimeState } from './runtime-projection.mjs';
4
+ import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
5
+ import {
6
+ RUN_EVENT_KINDS,
7
+ computeContextContentDigest,
8
+ selfDigest,
9
+ validateExecutorPacket,
10
+ validateExecutorReceipt,
11
+ validateRunEvent,
12
+ validateRuntimePlan,
13
+ verifyRuntimePlanBinding,
14
+ } from './runtime-contracts.mjs';
15
+
16
+ /**
17
+ * RC3-E ready-frontier runtime engine(ADR 0044 Decision 3・4・7.4・9、plan RC3-E)。
18
+ *
19
+ * event chainを生成するproducer。dispatch選択とreceipt裁定は
20
+ * `runtime-decision-verifier.mjs`と**独立の実装**で持ち、verifierは保存bytesだけから
21
+ * 全decisionを再計算する(producer非依存検証の二重実装が意図。同一コードの
22
+ * 自己照合にしない)。
23
+ *
24
+ * - minimum waveを同期barrierとして使わず、eventごとにhard predecessor・
25
+ * running conflict・実capacityからready frontierを再評価する(Decision 4)。
26
+ * - dispatchは必ずexecutor_packet.v1を伴い、dispatch記録(executor_handle・
27
+ * worktree_id・packet_digest)がreceipt帰属の唯一の基準になる(Decision 7.4/9.3)。
28
+ * - 稼働中TODOの重複dispatchはtyped rejectする。timeoutはfailureでなくunknownとして
29
+ * 扱い、同一handleの回収だけを許す(Decision 9.4)。
30
+ * - engineはevent配列を唯一のstate sourceにし、可変summaryを持たない(Decision 3.3)。
31
+ */
32
+
33
+ const ENGINE_ACTOR = 'lattice-runtime';
34
+ /**
35
+ * workerへ配る禁止操作。
36
+ *
37
+ * `commit`は含めない。自分の隔離worktreeでdetached HEADへ進めるcommitは、canonical branchを
38
+ * 動かさず外部へ効果を出さない一方で、進行中の成果を耐久化し、diff観測が生み出した木そのものを
39
+ * 縛れるようにする(ADR 0139)。
40
+ *
41
+ * 禁止のままにするのは、HEADをbaseの子孫から外す操作と、外部へ効果を出す操作である。
42
+ * 前者は観測の前提を壊し、後者は承認なしに行わないという公開契約に当たる。
43
+ */
44
+ const FORBIDDEN_OPERATIONS = Object.freeze([
45
+ 'push', 'branch', 'merge', 'rebase', 'reset', 'stash',
46
+ ]);
47
+
48
+ function fail(reason) {
49
+ throw new TypeError(`runtime engine契約違反: ${reason}`);
50
+ }
51
+
52
+ function plainRecord(value) {
53
+ return value !== null
54
+ && typeof value === 'object'
55
+ && !Array.isArray(value)
56
+ && Object.getPrototypeOf(value) === Object.prototype;
57
+ }
58
+
59
+ function exactRecord(value, keys) {
60
+ if (!plainRecord(value)) return false;
61
+ const actual = Object.keys(value).sort();
62
+ const expected = [...keys].sort();
63
+ return actual.length === expected.length
64
+ && actual.every((key, index) => key === expected[index]);
65
+ }
66
+
67
+ function requireTimestamp(value) {
68
+ if (!isCanonicalUtcTimestamp(value)) {
69
+ fail(`recordedAtがcanonical UTC timestampではない: ${String(value)}`);
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function sortedText(values) {
75
+ return [...values].sort();
76
+ }
77
+
78
+ /**
79
+ * 直前event prefixへchainされた新しいrun eventを構築する(append専用)。
80
+ */
81
+ export function buildNextRunEvent(options = {}) {
82
+ if (!exactRecord(options, ['events', 'runId', 'kind', 'planEpoch', 'subject', 'payload', 'recordedAt'])) {
83
+ fail('buildNextRunEvent optionsがexact shapeでない');
84
+ }
85
+ const { events, runId, kind, planEpoch, subject, payload, recordedAt } = options;
86
+ if (!Array.isArray(events)) fail('eventsがarrayではない');
87
+ if (!RUN_EVENT_KINDS.includes(kind)) fail(`未知のevent kind: ${String(kind)}`);
88
+ const previous = events.length === 0 ? null : events[events.length - 1];
89
+ // genesisはrun_initializedだけを許し、追記先prefixのrun同一性と末尾eventの
90
+ // 自己digest整合を検査する(別runへの追記・改竄済みprefixへの追記を塞ぐ)。
91
+ if (previous === null) {
92
+ if (kind !== 'run_initialized') fail(`genesis eventはrun_initializedでなければならない: ${kind}`);
93
+ } else {
94
+ if (previous.run_id !== runId) fail(`別runのprefixへ追記できない: ${String(previous.run_id)}`);
95
+ if (previous.event_digest !== digestRunEvent(previous)) {
96
+ fail('prefix末尾eventのdigestが自己整合しない(改竄または破損)');
97
+ }
98
+ }
99
+ const event = {
100
+ schema: 'lattice.run_event.v1',
101
+ run_id: runId,
102
+ sequence: previous === null ? 0 : previous.sequence + 1,
103
+ previous_digest: previous === null ? null : previous.event_digest,
104
+ kind,
105
+ actor: ENGINE_ACTOR,
106
+ plan_epoch: planEpoch,
107
+ subject: structuredClone(subject),
108
+ payload: structuredClone(payload),
109
+ recorded_at: requireTimestamp(recordedAt),
110
+ };
111
+ event.event_digest = digestRunEvent(event);
112
+ if (!validateRunEvent(event)) fail(`生成eventがrun_event.v1 contractを満たさない: ${kind}`);
113
+ return event;
114
+ }
115
+
116
+ /**
117
+ * run genesis: run_initialized+plan_compiledのevent prefixを作る。
118
+ */
119
+ export function initializeRunEvents(options = {}) {
120
+ if (!exactRecord(options, ['runId', 'request', 'plan', 'manifests', 'recordedAt'])) {
121
+ fail('initializeRunEvents optionsがexact shapeでない');
122
+ }
123
+ const { runId, request, plan, manifests, recordedAt } = options;
124
+ if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
125
+ if (!verifyRuntimePlanBinding({ plan, request })) fail('planがrequestへbindできない');
126
+ const events = [];
127
+ events.push(buildNextRunEvent({
128
+ events,
129
+ runId,
130
+ kind: 'run_initialized',
131
+ planEpoch: 0,
132
+ subject: { kind: 'run_request', ref: 'request.json' },
133
+ payload: { request_digest: request.request_digest },
134
+ recordedAt,
135
+ }));
136
+ events.push(buildNextRunEvent({
137
+ events,
138
+ runId,
139
+ kind: 'plan_compiled',
140
+ planEpoch: plan.plan_epoch,
141
+ subject: { kind: 'runtime_plan', ref: plan.plan_ref },
142
+ payload: {
143
+ plan_digest: plan.plan_digest,
144
+ manifest_digests: { ...plan.manifest_digests },
145
+ manifests_digest: digestArtifact(manifests),
146
+ },
147
+ recordedAt,
148
+ }));
149
+ return events;
150
+ }
151
+
152
+ /**
153
+ * boundary manifestからexecutor_packet.v1を構築する(Decision 9.3)。
154
+ */
155
+ export function buildExecutorPackets(options = {}) {
156
+ if (!exactRecord(options, ['plan', 'manifests'])) {
157
+ fail('buildExecutorPackets optionsがexact shapeでない');
158
+ }
159
+ const { plan, manifests } = options;
160
+ if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
161
+ const packets = {};
162
+ for (const node of plan.nodes) {
163
+ const manifest = manifests[node.todo_id];
164
+ if (!plainRecord(manifest) || manifest.manifest_digest !== plan.manifest_digests[node.todo_id]) {
165
+ fail(`manifestがplanのdigestと一致しない: ${node.todo_id}`);
166
+ }
167
+ const content = {
168
+ todo_id: node.todo_id,
169
+ task_ref: `${plan.plan_ref}-task-${node.todo_id}`,
170
+ scope: { writes: [...manifest.writes] },
171
+ base_sha: plan.base_sha,
172
+ verifier_refs: manifest.affected_tests.map((test) => `node --test ${test}`),
173
+ forbidden_operations: [...FORBIDDEN_OPERATIONS],
174
+ };
175
+ const packet = {
176
+ schema: 'lattice.executor_packet.v1',
177
+ packet_id: `${plan.plan_ref}-packet-${node.todo_id}`,
178
+ ...content,
179
+ plan_ref: plan.plan_ref,
180
+ plan_epoch: plan.plan_epoch,
181
+ context_content_digest: computeContextContentDigest(content),
182
+ };
183
+ packet.packet_digest = selfDigest(packet, 'packet_digest');
184
+ if (!validateExecutorPacket(packet)) fail(`生成packetがexecutor_packet.v1 contractを満たさない: ${node.todo_id}`);
185
+ packets[node.todo_id] = packet;
186
+ }
187
+ return packets;
188
+ }
189
+
190
+ /**
191
+ * engine側のready frontier選択(verifierと独立の実装)。
192
+ * hard predecessor充足・running/held除外・conflict pairの同時実行回避・
193
+ * 実capacityの空きだけを根拠に、辞書順の貪欲選択で決める。
194
+ */
195
+ function selectDispatchable(plan, state, events) {
196
+ if (state.freeze !== null || state.closed) return [];
197
+ const accepted = new Set(state.accepted);
198
+ const running = new Set(state.running);
199
+ // dispatch済み・terminal済み・heldの除外は現plan epochへscopeする
200
+ // (旧epoch分はcontext失効後にredispatch可能。RC3-G)。
201
+ const held = new Set();
202
+ const terminal = new Set();
203
+ const dispatched = new Set();
204
+ for (const event of events) {
205
+ if (event.plan_epoch !== plan.plan_epoch) continue;
206
+ if (event.kind === 'hold_decided') {
207
+ for (const todoId of event.payload?.hold_set ?? []) held.add(todoId);
208
+ }
209
+ if (event.subject?.kind !== 'todo') continue;
210
+ if (event.kind === 'executor_dispatched') dispatched.add(event.subject.ref);
211
+ if (event.kind === 'executor_terminal') terminal.add(event.subject.ref);
212
+ }
213
+ const free = plan.capacity.executors - running.size;
214
+ if (free < 1) return [];
215
+
216
+ const chosen = [];
217
+ for (const node of sortedText(plan.nodes.map((entry) => entry.todo_id))) {
218
+ if (chosen.length >= free) break;
219
+ if (accepted.has(node) || running.has(node) || held.has(node) || terminal.has(node)) continue;
220
+ if (dispatched.has(node)) continue; // 同一epoch内の再dispatchを塞ぐ
221
+ const predecessorsMet = plan.precedence.every((edge) => (
222
+ edge.to_todo_id !== node || accepted.has(edge.from_todo_id)
223
+ ));
224
+ if (!predecessorsMet) continue;
225
+ const conflictFree = plan.conflicts.every((conflict) => {
226
+ if (!conflict.todo_ids.includes(node)) return true;
227
+ return conflict.todo_ids.every((member) => (
228
+ member === node || (!running.has(member) && !chosen.includes(member))
229
+ ));
230
+ });
231
+ if (conflictFree) chosen.push(node);
232
+ }
233
+ return chosen;
234
+ }
235
+
236
+ /**
237
+ * ready frontierを評価し、dispatch_decided eventと、選択TODOごとの
238
+ * executor_dispatched eventを追記する。dispatchはadapterへ委ね、opaque handleだけを
239
+ * event payloadへ保存する。返り値は追記後のevents(入力配列は変更しない)。
240
+ */
241
+ export async function dispatchReadyFrontier(options = {}) {
242
+ if (!exactRecord(options, ['runId', 'plan', 'events', 'packets', 'manifests', 'adapter', 'recordedAt'])) {
243
+ fail('dispatchReadyFrontier optionsがexact shapeでない');
244
+ }
245
+ const { runId, plan, events, packets, manifests, adapter, recordedAt } = options;
246
+ if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
247
+ if (typeof adapter?.dispatch !== 'function') fail('adapterがdispatchを実装していない');
248
+
249
+ const state = projectRuntimeState({ events });
250
+ const dispatchable = selectDispatchable(plan, state, events);
251
+ let next = [...events];
252
+ next.push(buildNextRunEvent({
253
+ events: next,
254
+ runId,
255
+ kind: 'dispatch_decided',
256
+ planEpoch: plan.plan_epoch,
257
+ subject: { kind: 'runtime_plan', ref: plan.plan_ref },
258
+ payload: {
259
+ dispatchable: [...dispatchable],
260
+ running: [...state.running],
261
+ accepted: [...state.accepted],
262
+ capacity: plan.capacity.executors,
263
+ },
264
+ recordedAt,
265
+ }));
266
+
267
+ const dispatchedNow = [];
268
+ for (const todoId of dispatchable) {
269
+ const packet = packets[todoId];
270
+ if (!validateExecutorPacket(packet) || packet.todo_id !== todoId) {
271
+ fail(`dispatchにはexecutor_packet.v1が必要: ${todoId}`);
272
+ }
273
+ // packetはactive planへ帰属しなければならない。自己整合しただけの
274
+ // 別plan/別scope packetのdispatchを塞ぐ(review P1採用)。
275
+ const manifest = manifests[todoId];
276
+ if (packet.plan_ref !== plan.plan_ref
277
+ || packet.plan_epoch !== plan.plan_epoch
278
+ || packet.base_sha !== plan.base_sha
279
+ || packet.packet_id !== `${plan.plan_ref}-packet-${todoId}`
280
+ || packet.task_ref !== `${plan.plan_ref}-task-${todoId}`
281
+ || !plainRecord(manifest)
282
+ || manifest.manifest_digest !== plan.manifest_digests[todoId]
283
+ || JSON.stringify(packet.scope.writes) !== JSON.stringify(manifest.writes)) {
284
+ fail(`packetがactive planへ帰属しない: ${todoId}`);
285
+ }
286
+ // adapter失敗時も、成功済みdispatchの証拠を失わずtyped failureとして返す
287
+ // (起動済みexecutorをevent store外へ孤立させない。review P1採用)。
288
+ let dispatchResult;
289
+ try {
290
+ dispatchResult = await adapter.dispatch({ packet });
291
+ } catch (error) {
292
+ return {
293
+ events: next,
294
+ dispatched: dispatchedNow,
295
+ failure: { todo_id: todoId, message: String(error?.message ?? error) },
296
+ };
297
+ }
298
+ if (!plainRecord(dispatchResult)
299
+ || typeof dispatchResult.executor_handle !== 'string'
300
+ || dispatchResult.executor_handle.length === 0
301
+ || typeof dispatchResult.worktree_id !== 'string'
302
+ || dispatchResult.worktree_id.length === 0) {
303
+ fail(`adapter dispatchがopaque handle/worktreeを返さない: ${todoId}`);
304
+ }
305
+ const dispatchPayload = {
306
+ executor_handle: dispatchResult.executor_handle,
307
+ worktree_id: dispatchResult.worktree_id,
308
+ packet_digest: packet.packet_digest,
309
+ context_content_digest: packet.context_content_digest,
310
+ };
311
+ const managedFields = [
312
+ 'write_lease_id',
313
+ 'write_lease_digest',
314
+ 'controller_registration_digest',
315
+ 'controller_session_nonce_digest',
316
+ 'direct_os_observation_binding',
317
+ ];
318
+ if (managedFields.some((field) => Object.hasOwn(dispatchResult, field))) {
319
+ if (!managedFields.every((field) => Object.hasOwn(dispatchResult, field))) {
320
+ fail(`managed adapter dispatch bindingが不足する: ${todoId}`);
321
+ }
322
+ Object.assign(dispatchPayload, Object.fromEntries(
323
+ managedFields.map((field) => [field, structuredClone(dispatchResult[field])]),
324
+ ));
325
+ }
326
+ next.push(buildNextRunEvent({
327
+ events: next,
328
+ runId,
329
+ kind: 'executor_dispatched',
330
+ planEpoch: plan.plan_epoch,
331
+ subject: { kind: 'todo', ref: todoId },
332
+ payload: dispatchPayload,
333
+ recordedAt,
334
+ }));
335
+ dispatchedNow.push(todoId);
336
+ }
337
+ return { events: next, dispatched: dispatchedNow, failure: null };
338
+ }
339
+
340
+ /**
341
+ * 稼働executorをadapter経由で観測し、checkpoint/terminalをeventへ写す。
342
+ * timeout(unknown)はfailureへ丸めず、eventを追加しない(同一handleでの再観測だけを許す)。
343
+ */
344
+ export async function observeExecutor(options = {}) {
345
+ if (!exactRecord(options, ['runId', 'plan', 'events', 'adapter', 'todoId', 'recordedAt'])) {
346
+ fail('observeExecutor optionsがexact shapeでない');
347
+ }
348
+ const { runId, plan, events, adapter, todoId, recordedAt } = options;
349
+ if (typeof adapter?.observe !== 'function') fail('adapterがobserveを実装していない');
350
+ const state = projectRuntimeState({ events });
351
+ const dispatch = state.dispatches[todoId];
352
+ if (dispatch === undefined) fail(`未dispatchのTODOを観測できない: ${todoId}`);
353
+ // terminal報告済みexecutorの再観測は二重receipt(同一receipt再送での二重受理)
354
+ // の入口になるためfail closed(review P1採用)。guardは現plan epochへscopeする
355
+ // (旧epochでcontext_invalidated終端されたTODOのredispatch後観測を塞がない)。
356
+ const terminalInEpoch = events.some((event) => (
357
+ event.kind === 'executor_terminal'
358
+ && event.plan_epoch === plan.plan_epoch
359
+ && event.subject?.kind === 'todo'
360
+ && event.subject.ref === todoId
361
+ ));
362
+ if (terminalInEpoch) fail(`terminal報告済みTODOを再観測できない: ${todoId}`);
363
+
364
+ const observation = await adapter.observe({ executor_handle: dispatch.payload.executor_handle });
365
+ if (!plainRecord(observation) || typeof observation.state !== 'string') {
366
+ fail(`adapter observationが不正: ${todoId}`);
367
+ }
368
+
369
+ let next = [...events];
370
+ if (observation.state === 'unknown') {
371
+ return { events: next, observation: { state: 'unknown' } };
372
+ }
373
+ if (observation.state === 'checkpoint_ready') {
374
+ if (!plainRecord(observation.checkpoint)
375
+ || typeof observation.checkpoint.checkpoint_digest !== 'string'
376
+ || !/^[0-9a-f]{64}$/.test(observation.checkpoint.checkpoint_digest)) {
377
+ fail(`checkpoint payloadにはcheckpoint_digestが必要: ${todoId}`);
378
+ }
379
+ next.push(buildNextRunEvent({
380
+ events: next,
381
+ runId,
382
+ kind: 'checkpoint_observed',
383
+ planEpoch: plan.plan_epoch,
384
+ subject: { kind: 'todo', ref: todoId },
385
+ payload: structuredClone(observation.checkpoint),
386
+ recordedAt,
387
+ }));
388
+ return { events: next, observation: { state: 'checkpoint_ready' } };
389
+ }
390
+ if (observation.state === 'terminal') {
391
+ if (!validateExecutorReceipt(observation.receipt)) {
392
+ fail(`terminal receiptがexecutor_receipt.v1 contractを満たさない: ${todoId}`);
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
+ }
410
+ next.push(buildNextRunEvent({
411
+ events: next,
412
+ runId,
413
+ kind: 'receipt_recorded',
414
+ planEpoch: observation.receipt.plan_epoch,
415
+ subject: { kind: 'todo', ref: todoId },
416
+ payload: structuredClone(observation.receipt),
417
+ recordedAt,
418
+ }));
419
+ // cleanup失敗は成功へ丸めず、残存pathと回収条件をterminal eventへ保存する
420
+ // (plan RC3-F)。cleanup情報のないadapterは省略可。
421
+ const terminalPayload = {
422
+ executor_handle: dispatch.payload.executor_handle,
423
+ terminal_state: 'reported',
424
+ };
425
+ if (plainRecord(observation.cleanup)) {
426
+ terminalPayload.cleanup = structuredClone(observation.cleanup);
427
+ }
428
+ next.push(buildNextRunEvent({
429
+ events: next,
430
+ runId,
431
+ kind: 'executor_terminal',
432
+ planEpoch: plan.plan_epoch,
433
+ subject: { kind: 'todo', ref: todoId },
434
+ payload: terminalPayload,
435
+ recordedAt,
436
+ }));
437
+ return { events: next, observation: { state: 'terminal' } };
438
+ }
439
+ if (observation.state === 'hold_requested') {
440
+ // executorからの競合通報はconflict_found eventとして保存し、直後にevent intakeを
441
+ // freezeする(競合発見時にdispatchを続けるfail-open経路を塞ぐ。review P1採用)。
442
+ // affected closure計算とhold裁定・resumeはRC3-Gのhold契約が所有する。
443
+ if (!plainRecord(observation.finding)
444
+ || typeof observation.finding.kind !== 'string'
445
+ || observation.finding.kind.length === 0) {
446
+ fail(`hold requestにはfinding payloadが必要: ${todoId}`);
447
+ }
448
+ next.push(buildNextRunEvent({
449
+ events: next,
450
+ runId,
451
+ kind: 'conflict_found',
452
+ planEpoch: plan.plan_epoch,
453
+ subject: { kind: 'todo', ref: todoId },
454
+ payload: {
455
+ ...structuredClone(observation.finding),
456
+ reported_by: dispatch.payload.executor_handle,
457
+ },
458
+ recordedAt,
459
+ }));
460
+ next.push(buildNextRunEvent({
461
+ events: next,
462
+ runId,
463
+ kind: 'intake_frozen',
464
+ planEpoch: plan.plan_epoch,
465
+ subject: { kind: 'runtime_plan', ref: plan.plan_ref },
466
+ payload: {
467
+ frozen_prefix_digest: digestArtifact(next.map((event) => event.event_digest)),
468
+ reason_kind: observation.finding.kind,
469
+ },
470
+ recordedAt,
471
+ }));
472
+ return { events: next, observation: { state: 'hold_requested' } };
473
+ }
474
+ if (observation.state === 'running') {
475
+ return { events: next, observation: { state: 'running' } };
476
+ }
477
+ fail(`未知のadapter observation state: ${observation.state}`);
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * engine側のreceipt裁定(verifierと独立の実装。Decision 7.4の帰属規則)。
483
+ * 帰属の基準はdispatch記録(handle・worktree・packet digest)であり、
484
+ * executor自己申告を信用しない。裁定はreceipt_accepted/receipt_rejected eventへ保存する。
485
+ */
486
+ /**
487
+ * 直近のcheckpoint観測をdeclared scope/他running TODOへcross-bindし、
488
+ * findingがあればconflict_found+intake_frozenを追記する(RC3-F検出、裁定はRC3-G)。
489
+ */
490
+ export function classifyCheckpointObservation(options = {}) {
491
+ if (!exactRecord(options, ['runId', 'plan', 'events', 'packets', 'manifests', 'todoId', 'detect', 'recordedAt'])) {
492
+ fail('classifyCheckpointObservation optionsがexact shapeでない');
493
+ }
494
+ const { runId, plan, events, packets, manifests, todoId, detect, recordedAt } = options;
495
+ if (typeof detect !== 'function') fail('detect関数が必要');
496
+ const state = projectRuntimeState({ events });
497
+ const checkpoints = state.checkpoints.filter((entry) => entry.todo_id === todoId);
498
+ if (checkpoints.length === 0) fail(`checkpoint未観測のTODOを分類できない: ${todoId}`);
499
+ const checkpoint = checkpoints[checkpoints.length - 1].payload;
500
+ const { findings: detectedFindings } = detect({
501
+ todoId,
502
+ checkpoint,
503
+ packets,
504
+ manifests,
505
+ runningTodoIds: state.running,
506
+ });
507
+ if (!Array.isArray(detectedFindings)) fail('detectがfindings配列を返さない');
508
+ const observations = detectedFindings
509
+ .filter((finding) => finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1)
510
+ .map((finding) => ({ ...finding, kind: 'prediction_excess' }));
511
+ const scopeExpanded = observations.length === 0 ? [] : (() => {
512
+ const manifest = manifests[todoId];
513
+ const predictedPaths = sortedText(new Set(manifest?.writes ?? []));
514
+ const addedPaths = sortedText(new Set(observations.map((observation) => {
515
+ const location = observation.path ?? observation.resource_id;
516
+ if (typeof location !== 'string' || location.length === 0) {
517
+ fail('prediction_excessにpathまたはresource_idがない');
518
+ }
519
+ return location;
520
+ })));
521
+ const currentPaths = new Set([...predictedPaths, ...addedPaths]);
522
+ const incoming = (plan.precedence ?? [])
523
+ .filter((edge) => edge.to_todo_id === todoId)
524
+ .length;
525
+ return [{
526
+ task_id: todoId,
527
+ compared_witness_digest: null,
528
+ first_seen_path_count: predictedPaths.length,
529
+ path_count: currentPaths.size,
530
+ added_paths: addedPaths,
531
+ removed_paths: [],
532
+ growth_events: 1,
533
+ gate_shape: incoming >= 2,
534
+ }];
535
+ })();
536
+ const findings = detectedFindings.filter((finding) => !(
537
+ finding.kind === 'undeclared_write' && finding.todo_ids?.length === 1
538
+ ));
539
+ // 再分類のidempotence: path/resourceのどちらであっても、同じfindingだけ再記録しない。
540
+ const recordedKeys = new Set(state.conflicts.map((conflict) => (
541
+ `${conflict.kind}|${[...(conflict.todo_ids ?? [])].sort().join(',')}|${conflict.path ?? ''}|${conflict.resource_id ?? ''}`
542
+ )));
543
+ const freshFindings = findings.filter((finding) => !recordedKeys.has(
544
+ `${finding.kind}|${[...(finding.todo_ids ?? [])].sort().join(',')}|${finding.path ?? ''}|${finding.resource_id ?? ''}`,
545
+ ));
546
+ let next = [...events];
547
+ for (const finding of freshFindings) {
548
+ next.push(buildNextRunEvent({
549
+ events: next,
550
+ runId,
551
+ kind: 'conflict_found',
552
+ planEpoch: plan.plan_epoch,
553
+ subject: { kind: 'todo', ref: todoId },
554
+ payload: structuredClone(finding),
555
+ recordedAt,
556
+ }));
557
+ }
558
+ if (freshFindings.length > 0 && state.freeze === null) {
559
+ next.push(buildNextRunEvent({
560
+ events: next,
561
+ runId,
562
+ kind: 'intake_frozen',
563
+ planEpoch: plan.plan_epoch,
564
+ subject: { kind: 'runtime_plan', ref: plan.plan_ref },
565
+ payload: {
566
+ frozen_prefix_digest: digestArtifact(next.map((event) => event.event_digest)),
567
+ reason_kind: findings[0].kind,
568
+ },
569
+ recordedAt,
570
+ }));
571
+ }
572
+ return {
573
+ events: next,
574
+ findings: freshFindings,
575
+ observations,
576
+ scope_expanded: scopeExpanded,
577
+ };
578
+ }
579
+
580
+ const RECEIPT_BINDING_FIELDS = Object.freeze([
581
+ 'executor_handle',
582
+ 'worktree_id',
583
+ 'base_sha',
584
+ 'packet_digest',
585
+ 'checkpoint_digest',
586
+ ]);
587
+
588
+ function witnessProvenReceiptBinding(state, receipt) {
589
+ const witnessRecord = state.witnesses[receipt.todo_id];
590
+ const witness = witnessRecord?.payload?.witness;
591
+ if (witness === null || witness === undefined) return false;
592
+ if (witness.todo_id !== receipt.todo_id) return false;
593
+ if (typeof witnessRecord.payload.witness_digest !== 'string'
594
+ || witnessRecord.payload.witness_digest !== witness.witness_digest
595
+ || witness.witness_digest !== selfDigest(witness, 'witness_digest')) {
596
+ return false;
597
+ }
598
+ return Array.isArray(witness.receipt_bindings) && witness.receipt_bindings.some((binding) => (
599
+ binding.receipt_id === receipt.receipt_id
600
+ && binding.recorded_sequence === receipt.sequence
601
+ && binding.within_frozen_prefix === true
602
+ ));
603
+ }
604
+
605
+ function retainsOriginBinding(events, receipt, currentEpoch) {
606
+ if (!Number.isSafeInteger(receipt.plan_epoch) || receipt.plan_epoch >= currentEpoch) return false;
607
+ for (let epoch = receipt.plan_epoch; epoch < currentEpoch; epoch += 1) {
608
+ const witnessed = events.some((event) => event.sequence < receipt.sequence
609
+ && event.kind === 'carry_over_witnessed' && event.plan_epoch === epoch
610
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
611
+ const continued = events.some((event) => event.sequence < receipt.sequence
612
+ && event.kind === 'hold_decided' && event.plan_epoch === epoch
613
+ && event.payload?.continue_set?.includes(receipt.todo_id));
614
+ const recompiled = events.some((event) => event.sequence < receipt.sequence
615
+ && event.kind === 'plan_recompiled' && event.plan_epoch === epoch + 1);
616
+ const invalidated = events.some((event) => event.sequence < receipt.sequence
617
+ && event.kind === 'context_invalidated' && event.plan_epoch === epoch + 1
618
+ && event.subject?.kind === 'todo' && event.subject.ref === receipt.todo_id);
619
+ if (!witnessed || !continued || !recompiled || invalidated) return false;
620
+ }
621
+ return true;
622
+ }
623
+
624
+ export function adjudicatePendingReceipts(options = {}) {
625
+ if (!exactRecord(options, ['runId', 'plan', 'events', 'recordedAt'])) {
626
+ fail('adjudicatePendingReceipts optionsがexact shapeでない');
627
+ }
628
+ const { runId, plan, events, recordedAt } = options;
629
+ if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
630
+ const state = projectRuntimeState({ events });
631
+ // freezeはresumeで消えない永続境界(Decision 7.4のstale判定基準は最初のfreeze)。
632
+ // 境界は現plan epochで発生したfreezeへscopeする(vN+1 receiptをvNのfreezeで
633
+ // 塞がない。verifierと同一規則)。
634
+ const freezes = events
635
+ .filter((event) => event.kind === 'intake_frozen' && event.plan_epoch === plan.plan_epoch)
636
+ .map((event) => event.sequence)
637
+ .sort((left, right) => left - right);
638
+ const freezeBoundary = freezes.length === 0 ? null : freezes[0];
639
+ const seenReceiptIds = new Set();
640
+ for (const receipt of state.receipts) {
641
+ if (receipt.accepted_sequence !== null || receipt.rejected_sequence !== null) {
642
+ seenReceiptIds.add(receipt.receipt_id);
643
+ }
644
+ }
645
+
646
+ let next = [...events];
647
+ const decisions = [];
648
+ for (const receipt of state.receipts) {
649
+ if (receipt.accepted_sequence !== null || receipt.rejected_sequence !== null) continue;
650
+ const payload = receipt.payload ?? {};
651
+ const dispatch = state.dispatches[receipt.todo_id];
652
+ const originBindingRetained = retainsOriginBinding(events, receipt, plan.plan_epoch);
653
+ let rejection = null;
654
+ if (seenReceiptIds.has(receipt.receipt_id)) {
655
+ rejection = 'duplicate_receipt_id';
656
+ } else if (RECEIPT_BINDING_FIELDS.some((field) => typeof payload[field] !== 'string')) {
657
+ rejection = 'binding_missing';
658
+ } else if (dispatch === undefined) {
659
+ rejection = 'not_dispatched';
660
+ } else if (payload.executor_handle !== dispatch.payload.executor_handle
661
+ || payload.worktree_id !== dispatch.payload.worktree_id
662
+ || payload.packet_digest !== dispatch.payload.packet_digest) {
663
+ rejection = 'binding_mismatch';
664
+ } else if (payload.base_sha !== plan.base_sha) {
665
+ rejection = 'base_mismatch';
666
+ } else if (receipt.plan_epoch !== plan.plan_epoch && !originBindingRetained) {
667
+ rejection = 'epoch_mismatch';
668
+ } else if ((() => {
669
+ // dispatchが旧epochのTODOが現epochのreceiptを名乗る場合、epoch_rebound
670
+ // event(rebindのsequenceがreceiptより前、new_plan_epoch一致)を必須にする。
671
+ // rebindなしのepoch自称は旧contextの偽装であり受理しない(Decision 7.3/7.4)。
672
+ const dispatchEvent = events.find((event) => (
673
+ event.kind === 'executor_dispatched'
674
+ && event.subject?.kind === 'todo'
675
+ && event.subject.ref === receipt.todo_id
676
+ && event.sequence === dispatch.sequence
677
+ ));
678
+ if (originBindingRetained || dispatchEvent === undefined
679
+ || dispatchEvent.plan_epoch === plan.plan_epoch) return false;
680
+ const rebound = state.rebinds[receipt.todo_id];
681
+ return rebound === undefined
682
+ || rebound.payload?.new_plan_epoch !== plan.plan_epoch
683
+ || rebound.sequence >= receipt.sequence;
684
+ })()) {
685
+ rejection = 'unrebound_epoch';
686
+ } else if ((() => {
687
+ // receipt以前の最後の観測checkpointへのbindを要求(digestとobserved_diff。
688
+ // executor自己申告をbinding証拠にしない。RC3-F)。bindingは同一dispatch
689
+ // attemptへscopeする=最後のdispatch以降のcheckpointだけが対象
690
+ // (redispatch後のreceiptを旧attemptのcheckpointで塞がない。RC3-G)。
691
+ const attemptStart = dispatch.sequence;
692
+ const observedCheckpoints = state.checkpoints.filter((entry) => (
693
+ entry.todo_id === receipt.todo_id
694
+ && entry.sequence > attemptStart
695
+ && entry.sequence < receipt.sequence
696
+ // supervisorが自分の判断で撮ったcheckpoint(I/O警報のprobe)は、executorの
697
+ // 申告境界ではない。走行中の任意の一点なので、その後も書き続けたexecutorの
698
+ // receiptと一致しないのが正常である。ここへ混ぜると、正当なreceiptが
699
+ // checkpoint_mismatchで落ちる。証拠としては残り、findingの導出には使われる。
700
+ && entry.payload?.observed_by !== 'supervisor_probe'
701
+ ));
702
+ if (observedCheckpoints.length === 0) return false;
703
+ const last = observedCheckpoints[observedCheckpoints.length - 1].payload;
704
+ if (typeof last?.checkpoint_digest === 'string'
705
+ && payload.checkpoint_digest !== last.checkpoint_digest) {
706
+ return true;
707
+ }
708
+ if (last?.diff?.entries !== undefined && Array.isArray(last.diff.entries)) {
709
+ const expected = JSON.stringify(last.diff.entries.map(({ path: p, change }) => ({ path: p, change })));
710
+ const reported = JSON.stringify((payload.observed_diff ?? []).map(({ path: p, change }) => ({ path: p, change })));
711
+ if (expected !== reported) return true;
712
+ }
713
+ return false;
714
+ })()) {
715
+ rejection = 'checkpoint_mismatch';
716
+ } else if (freezeBoundary !== null) {
717
+ // freeze境界を跨いだreceiptはstale(Decision 7.4)。frozen prefix内のreceiptは
718
+ // 実証済みcarry-over witnessのbindingがある場合だけ受理(Decision 7.5)。
719
+ if (receipt.sequence > freezeBoundary) {
720
+ rejection = 'post_freeze';
721
+ } else if (!witnessProvenReceiptBinding(state, receipt)) {
722
+ rejection = 'witness_unproven';
723
+ }
724
+ }
725
+ seenReceiptIds.add(receipt.receipt_id);
726
+ if (rejection === null) {
727
+ next.push(buildNextRunEvent({
728
+ events: next,
729
+ runId,
730
+ kind: 'receipt_accepted',
731
+ planEpoch: plan.plan_epoch,
732
+ subject: { kind: 'todo', ref: receipt.todo_id },
733
+ payload: { receipt_id: receipt.receipt_id, checkpoint_digest: payload.checkpoint_digest ?? null },
734
+ recordedAt,
735
+ }));
736
+ decisions.push({ receipt_id: receipt.receipt_id, decision: 'accepted' });
737
+ } else {
738
+ next.push(buildNextRunEvent({
739
+ events: next,
740
+ runId,
741
+ kind: 'receipt_rejected',
742
+ planEpoch: plan.plan_epoch,
743
+ subject: { kind: 'todo', ref: receipt.todo_id },
744
+ payload: { receipt_id: receipt.receipt_id, reason: 'stale_context', detail: rejection },
745
+ recordedAt,
746
+ }));
747
+ decisions.push({ receipt_id: receipt.receipt_id, decision: 'rejected', detail: rejection });
748
+ }
749
+ }
750
+ return { events: next, decisions };
751
+ }
752
+
753
+ /**
754
+ * 全TODO terminalかつpending receipt 0の時だけrun_closedを追記する。
755
+ */
756
+ export function closeRunIfComplete(options = {}) {
757
+ if (!exactRecord(options, ['runId', 'plan', 'events', 'recordedAt'])) {
758
+ fail('closeRunIfComplete optionsがexact shapeでない');
759
+ }
760
+ const { runId, plan, events, recordedAt } = options;
761
+ const state = projectRuntimeState({ events });
762
+ const nodeIds = plan.nodes.map((node) => node.todo_id);
763
+ const done = nodeIds.every((todoId) => state.accepted.includes(todoId));
764
+ const pending = state.receipts.some((receipt) => (
765
+ receipt.accepted_sequence === null && receipt.rejected_sequence === null
766
+ ));
767
+ if (!done || pending || state.closed) return { events, closed: state.closed };
768
+ const next = [...events];
769
+ next.push(buildNextRunEvent({
770
+ events: next,
771
+ runId,
772
+ kind: 'run_closed',
773
+ planEpoch: plan.plan_epoch,
774
+ subject: { kind: 'runtime_plan', ref: plan.plan_ref },
775
+ payload: { accepted: sortedText(state.accepted) },
776
+ recordedAt,
777
+ }));
778
+ return { events: next, closed: true };
779
+ }