@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.
- package/LICENSE +147 -147
- package/README.ja.md +355 -355
- package/README.md +258 -258
- package/bin/lattice-mcp.mjs +0 -0
- package/bin/lattice-scripted-adapter.mjs +0 -0
- package/bin/lattice-scripted-worker.mjs +0 -0
- package/bin/lattice-work-order-adapter.mjs +0 -0
- package/bin/lattice.mjs +0 -0
- package/docs/bridge-setup.md +132 -132
- package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
- package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
- package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
- package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
- package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
- package/package.json +103 -103
- package/sensor/LICENSE +21 -21
- package/sensor/NOTICE +19 -19
- package/sensor/dist/bin/lattice-sensor.js +9 -9
- package/sensor/dist/db/index.js +24 -24
- package/sensor/dist/db/migrations.js +41 -41
- package/sensor/dist/db/queries.js +164 -164
- package/sensor/dist/db/schema.sql +205 -205
- package/sensor/dist/directory.js +5 -5
- package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
- package/sensor/dist/mcp/liveness-watchdog.js +53 -53
- package/sensor/dist/mcp/server-instructions.js +95 -95
- package/sensor/package.json +56 -56
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1053
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +697 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1502
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -520
- package/src/runtime-cli.mjs +4588 -4584
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -600
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -162
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -477
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -290
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -834
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1188
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1167
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3141 -3126
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -625
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4322 -4314
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
package/src/runtime-engine.mjs
CHANGED
|
@@ -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
|
+
}
|