@quolu/lattice 0.52.0 → 0.52.2

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 (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. 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
+ }