@quolu/lattice 0.52.3 → 0.53.0

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