@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,1192 +1,1192 @@
1
- import { execFile, spawn } from 'node:child_process';
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { constants as fsConstants } from 'node:fs';
4
- import {
5
- lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile,
6
- } from 'node:fs/promises';
7
- import path from 'node:path';
8
- import { promisify } from 'node:util';
9
-
10
- import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
11
- import { validateExpectedWorkerProcess } from './runtime-controller-protocol.mjs';
12
- import { classifyObservedDiff } from './runtime-decision-verifier.mjs';
13
- import { captureWorktreeDiff, detectCheckpointFindings } from './runtime-diff-observer.mjs';
14
- import { acquireRuntimeLifecycleLock } from './runtime-lifecycle-lock.mjs';
15
- import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
16
- import { ensureScriptedWorktree } from './runtime-scripted-worktree.mjs';
17
- import {
18
- BOUNDARY_MANIFEST_SCHEMA, selfDigest, validateRuntimeBoundaryManifest, validateRuntimePlan,
19
- } from './runtime-contracts.mjs';
20
- import { TODO_INDEPENDENCE_SCHEMA } from './todo-independence-contracts.mjs';
21
- import {
22
- readTodoIndependenceArtifact, readTodoStore, readTodoWitnessSet,
23
- } from './todo-store.mjs';
24
-
25
- export const PULL_RUN_META_SCHEMA = 'lattice.pull_run_meta.v1';
26
- const PULL_EVENT_SCHEMA = 'lattice.pull_run_event.v1';
27
- const PULL_EVENTS_FILE = 'pull-events.json';
28
- const LEGACY_META_SCHEMAS = new Set(['lattice.run_meta.v1', 'lattice.run_meta.v2']);
29
- const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
30
- const SHA1 = /^[0-9a-f]{40}$/u;
31
- const SHA256 = /^[0-9a-f]{64}$/u;
32
- const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
33
- const MAX_FILE_BYTES = 8_388_608;
34
- const execFileAsync = promisify(execFile);
35
-
36
- export class PullRunError extends Error {
37
- constructor(code, message, detail) {
38
- super(message);
39
- this.name = 'PullRunError';
40
- this.code = code;
41
- this.detail = detail;
42
- }
43
- }
44
-
45
- function fail(code, message, detail) { throw new PullRunError(code, message, detail); }
46
- function now() { return new Date().toISOString(); }
47
- function plain(value) {
48
- return value !== null && typeof value === 'object' && !Array.isArray(value)
49
- && Object.getPrototypeOf(value) === Object.prototype;
50
- }
51
- function exact(value, keys) {
52
- return plain(value) && Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
53
- }
54
- function identifier(value) { return typeof value === 'string' && ID.test(value); }
55
- function sha256(value) { return typeof value === 'string' && SHA256.test(value); }
56
- function timestamp(value) {
57
- return typeof value === 'string' && TIMESTAMP.test(value)
58
- && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
59
- }
60
-
61
- function run(command, args, cwd, allowExitCodes = [0]) {
62
- return new Promise((resolve, reject) => {
63
- const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
64
- const stdout = [];
65
- const stderr = [];
66
- child.stdout.on('data', (chunk) => stdout.push(chunk));
67
- child.stderr.on('data', (chunk) => stderr.push(chunk));
68
- child.once('error', reject);
69
- child.once('close', (code, signal) => {
70
- const output = {
71
- code, signal,
72
- stdout: Buffer.concat(stdout).toString('utf8'),
73
- stderr: Buffer.concat(stderr).toString('utf8'),
74
- };
75
- if (signal === null && allowExitCodes.includes(code)) resolve(output);
76
- else reject(new PullRunError(
77
- 'GIT_OBSERVATION_FAILED', `${command} ${args.join(' ')} failed`, output,
78
- ));
79
- });
80
- });
81
- }
82
-
83
- async function readJson(filePath, label) {
84
- let info;
85
- try { info = await lstat(filePath); }
86
- catch { fail('INVALID_RUN_STORE', `${label}を読めない`); }
87
- if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES) {
88
- fail('INVALID_RUN_STORE', `${label}が安全なbounded regular fileでない`);
89
- }
90
- let value;
91
- try { value = JSON.parse(await readFile(filePath, 'utf8')); }
92
- catch { fail('INVALID_RUN_STORE', `${label}がJSONとして不正`); }
93
- return value;
94
- }
95
-
96
- async function syncDirectory(directory) {
97
- const handle = await open(directory, fsConstants.O_RDONLY);
98
- // Windowsはdirectory handleのfsyncを許さず常にEPERM/EINVALを返す(Node仕様)。
99
- // win32のこの2値だけ許容し、他OS・他エラーは従来どおり失敗させる。
100
- try { await handle.sync(); } catch (error) {
101
- if (process.platform !== 'win32' || !['EPERM', 'EINVAL'].includes(error?.code)) throw error;
102
- } finally { await handle.close(); }
103
- }
104
-
105
- async function writeCanonicalNew(filePath, value) {
106
- await writeFile(filePath, `${canonicalizeArtifact(value)}\n`, { mode: 0o600, flag: 'wx' });
107
- }
108
-
109
- async function replaceCanonical(runDir, name, value) {
110
- const temporary = path.join(runDir, `.${name}-${process.pid}-${randomUUID()}.tmp`);
111
- let handle;
112
- try {
113
- handle = await open(temporary,
114
- fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
115
- await handle.writeFile(Buffer.from(`${canonicalizeArtifact(value)}\n`));
116
- await handle.sync();
117
- await handle.close();
118
- handle = null;
119
- await rename(temporary, path.join(runDir, name));
120
- await syncDirectory(runDir);
121
- } finally {
122
- if (handle) await handle.close().catch(() => {});
123
- await rm(temporary, { force: true }).catch(() => {});
124
- }
125
- }
126
-
127
- function validateMeta(meta) {
128
- return exact(meta, [
129
- 'schema', 'run_id', 'plan_key', 'selection', 'equipment', 'created_at', 'meta_digest',
130
- ])
131
- && meta.schema === PULL_RUN_META_SCHEMA
132
- && identifier(meta.run_id) && identifier(meta.plan_key)
133
- && meta.selection === 'pull' && meta.equipment === 'detached-worktree'
134
- && typeof meta.created_at === 'string'
135
- && sha256(meta.meta_digest) && selfDigest(meta, 'meta_digest') === meta.meta_digest;
136
- }
137
-
138
- export async function inspectRunMode(runDir) {
139
- const meta = await readJson(path.join(runDir, 'run-meta.json'), 'run meta');
140
- if (meta?.schema === PULL_RUN_META_SCHEMA) {
141
- if (!validateMeta(meta)) fail('INVALID_RUN_STORE', 'pull run meta bindingが不正');
142
- for (const legacyName of ['request.json', 'plan-compile-result.json', 'events.json']) {
143
- try {
144
- await lstat(path.join(runDir, legacyName));
145
- fail('MIXED_RUN_STORE', `pull storeへlegacy artifactが混在: ${legacyName}`);
146
- } catch (error) {
147
- if (error instanceof PullRunError) throw error;
148
- if (error?.code !== 'ENOENT') fail('INVALID_RUN_STORE', `${legacyName}の存在確認に失敗`);
149
- }
150
- }
151
- return { mode: 'pull', meta };
152
- }
153
- if (LEGACY_META_SCHEMAS.has(meta?.schema)) {
154
- try {
155
- await lstat(path.join(runDir, PULL_EVENTS_FILE));
156
- fail('MIXED_RUN_STORE', `legacy storeへpull artifactが混在: ${PULL_EVENTS_FILE}`);
157
- } catch (error) {
158
- if (error instanceof PullRunError) throw error;
159
- if (error?.code !== 'ENOENT') fail('INVALID_RUN_STORE', `${PULL_EVENTS_FILE}の存在確認に失敗`);
160
- }
161
- return { mode: 'legacy', meta };
162
- }
163
- fail('UNSUPPORTED_RUN_STORE_SCHEMA', '未知のrun meta schema', { schema: meta?.schema ?? null });
164
- }
165
-
166
- function buildEvent({ events, meta, kind, taskId = null, payload }) {
167
- const event = {
168
- schema: PULL_EVENT_SCHEMA,
169
- run_id: meta.run_id,
170
- sequence: events.length,
171
- previous_event_digest: events.at(-1)?.event_digest ?? null,
172
- kind,
173
- task_id: taskId,
174
- recorded_at: now(),
175
- payload: structuredClone(payload),
176
- event_digest: '',
177
- };
178
- event.event_digest = selfDigest(event, 'event_digest');
179
- return event;
180
- }
181
-
182
- function verifyEvents(events, meta) {
183
- if (!Array.isArray(events) || events.length < 1) fail('INVALID_PULL_EVENT_CHAIN', 'pull event列が空');
184
- let previous = null;
185
- for (const [index, event] of events.entries()) {
186
- if (!exact(event, [
187
- 'schema', 'run_id', 'sequence', 'previous_event_digest', 'kind', 'task_id',
188
- 'recorded_at', 'payload', 'event_digest',
189
- ])
190
- || event.schema !== PULL_EVENT_SCHEMA || event.run_id !== meta.run_id
191
- || event.sequence !== index || event.previous_event_digest !== previous
192
- || !sha256(event.event_digest) || selfDigest(event, 'event_digest') !== event.event_digest
193
- || typeof event.kind !== 'string' || !(event.task_id === null || identifier(event.task_id))
194
- || !plain(event.payload)) {
195
- fail('INVALID_PULL_EVENT_CHAIN', `pull event chainが不正: sequence ${index}`);
196
- }
197
- previous = event.event_digest;
198
- }
199
- }
200
-
201
- async function readPullStore(runDir) {
202
- const { mode, meta } = await inspectRunMode(runDir);
203
- if (mode !== 'pull') fail('RUN_MODE_MISMATCH', 'pull commandへlegacy runを渡せない');
204
- const events = await readJson(path.join(runDir, PULL_EVENTS_FILE), 'pull events');
205
- verifyEvents(events, meta);
206
- return { meta, events };
207
- }
208
-
209
- function project(events, meta) {
210
- const intakes = new Map();
211
- let closed = false;
212
- for (const event of events) {
213
- if (event.kind === 'intake_recorded') {
214
- intakes.set(event.task_id, {
215
- task_id: event.task_id,
216
- sequence: event.sequence,
217
- ...structuredClone(event.payload),
218
- worker: null,
219
- accepted: null,
220
- });
221
- } else if (event.kind === 'intake_refreshed') {
222
- const intake = intakes.get(event.task_id);
223
- if (intake) Object.assign(intake, {
224
- manifest: structuredClone(event.payload.manifest),
225
- independence_result_digest: event.payload.independence_result_digest,
226
- witness_set_digest: event.payload.witness_set_digest,
227
- intervention: structuredClone(event.payload.intervention),
228
- });
229
- } else if (event.kind === 'intervention_changed') {
230
- const intake = intakes.get(event.task_id);
231
- if (intake) intake.intervention = structuredClone(event.payload.intervention);
232
- } else if (event.kind === 'worker_attached') {
233
- const intake = intakes.get(event.task_id);
234
- if (intake) intake.worker = { ...structuredClone(event.payload), stopped: false };
235
- } else if (event.kind === 'worker_stopped' || event.kind === 'worker_resumed') {
236
- const intake = intakes.get(event.task_id);
237
- if (intake?.worker) intake.worker.stopped = event.kind === 'worker_stopped';
238
- } else if (event.kind === 'task_accepted') {
239
- const intake = intakes.get(event.task_id);
240
- if (intake) intake.accepted = structuredClone(event.payload);
241
- } else if (event.kind === 'intake_released') {
242
- const intake = intakes.get(event.task_id);
243
- if (intake?.accepted === null) intakes.delete(event.task_id);
244
- } else if (event.kind === 'run_closed') closed = true;
245
- }
246
- return {
247
- run_id: meta.run_id,
248
- plan_key: meta.plan_key,
249
- plan_version: [...intakes.values()][0]?.plan_version ?? null,
250
- intakes: [...intakes.values()].sort((left, right) => left.sequence - right.sequence),
251
- closed,
252
- };
253
- }
254
-
255
- function publicIntake(intake) {
256
- return {
257
- task_id: intake.task_id,
258
- actor: structuredClone(intake.actor),
259
- plan_version: intake.plan_version,
260
- activation_event_digest: intake.activation_event_digest,
261
- base_sha: intake.base_sha,
262
- worktree_path: intake.worktree_path,
263
- packet_digest: intake.packet.packet_digest,
264
- manifest_digest: intake.manifest.manifest_digest,
265
- independence_result_digest: intake.independence_result_digest,
266
- witness_set_digest: intake.witness_set_digest,
267
- intervention: structuredClone(intake.intervention),
268
- worker_attached: intake.worker !== null,
269
- worker_stopped: intake.worker?.stopped ?? false,
270
- accepted_head_sha: intake.accepted?.head_sha ?? null,
271
- };
272
- }
273
-
274
- function actorFromEnvironment(environment = process.env) {
275
- const actor = {
276
- host: environment.LATTICE_TODO_ACTOR_HOST,
277
- session: environment.LATTICE_TODO_ACTOR_SESSION,
278
- agent: environment.LATTICE_TODO_ACTOR_AGENT,
279
- };
280
- if (!Object.values(actor).every(identifier)) {
281
- fail('TODO_ACTOR_REQUIRED', 'intakeにはTodo startと同じactor host/session/agentが必要');
282
- }
283
- return actor;
284
- }
285
-
286
- function sameActor(left, right) {
287
- return left?.host === right.host && left?.session === right.session && left?.agent === right.agent;
288
- }
289
-
290
- function activeMember(store, planKey) {
291
- const member = store.members?.find((entry) => entry.plan?.plan_key === planKey);
292
- if (!member) fail('PLAN_NOT_ACTIVE', `active planが見つからない: ${planKey}`);
293
- return member;
294
- }
295
-
296
- function literalEvent(member, { kind, taskId, actor }) {
297
- return member.journal?.events?.findLast((event) => (
298
- event.kind === kind && event.task_id === taskId
299
- && event.plan_version === member.plan.plan_version && sameActor(event.actor, actor)
300
- )) ?? null;
301
- }
302
-
303
- function resolveStartBinding(store, meta, taskId, actor) {
304
- const member = activeMember(store, meta.plan_key);
305
- const task = member.tasks.find((entry) => entry.task_id === taskId);
306
- if (!task || task.status === 'pending') fail('TASK_NOT_STARTED', `ToDoがstartされていない: ${taskId}`);
307
- if (task.status !== 'in-progress') {
308
- fail('TASK_START_BINDING_UNSUPPORTED', `in-progressのliteral startだけをintakeできる: ${taskId}`);
309
- }
310
- const activation = literalEvent(member, { kind: 'start', taskId, actor });
311
- if (activation === null) {
312
- fail('TASK_START_BINDING_UNSUPPORTED', 'reopen/carry/importをliteral startへ推定しない');
313
- }
314
- return { member, task, activation };
315
- }
316
-
317
- async function headSha(repoRoot) {
318
- const result = await run('git', ['rev-parse', 'HEAD'], repoRoot);
319
- const value = result.stdout.trim();
320
- if (!SHA1.test(value)) fail('REPO_UNRESOLVED', 'canonical HEADを解決できない');
321
- return value;
322
- }
323
-
324
- function boundaryFor(artifact, taskId) {
325
- return artifact?.task_boundaries?.find((entry) => entry.task_id === taskId) ?? null;
326
- }
327
-
328
- function pathTouches(boundary, changed) {
329
- return boundary === changed
330
- || (boundary.endsWith('/') && changed.startsWith(boundary))
331
- || (changed.endsWith('/') && boundary.startsWith(changed));
332
- }
333
-
334
- async function boundaryVerdict({ repoRoot, store, member, meta, taskId, intakeBase }) {
335
- let artifact;
336
- let witnessSet;
337
- try {
338
- [artifact, witnessSet] = await Promise.all([
339
- readTodoIndependenceArtifact({ repoRoot, store, planKey: meta.plan_key }),
340
- readTodoWitnessSet({ repoRoot, planKey: meta.plan_key }),
341
- ]);
342
- } catch (error) {
343
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
344
- detail: { cause: error?.code ?? 'artifact_read_failed' }, artifact: null, witnessSet: null };
345
- }
346
- const bindingValid = artifact !== null
347
- && artifact.schema === TODO_INDEPENDENCE_SCHEMA
348
- && artifact.project_id === store.project_id
349
- && artifact.plan_key === meta.plan_key
350
- && artifact.plan_version === member.plan.plan_version
351
- && artifact.topology_digest === member.plan.topology_digest
352
- && witnessSet?.project_id === store.project_id
353
- && witnessSet?.plan_key === meta.plan_key
354
- && witnessSet?.witness_set_digest === artifact.witness_set_digest;
355
- if (!bindingValid) {
356
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
357
- detail: { cause: 'artifact_binding_mismatch' }, artifact, witnessSet };
358
- }
359
- if (artifact.outcome !== 'compiled') {
360
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
361
- detail: { cause: 'independence_outcome_unknown' }, artifact, witnessSet };
362
- }
363
- const boundary = boundaryFor(artifact, taskId);
364
- if (boundary === null || !Array.isArray(boundary.paths)) {
365
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'declare_and_recompile',
366
- detail: { cause: 'task_boundary_missing' }, artifact, witnessSet };
367
- }
368
- if (!SHA1.test(artifact.base_sha)) {
369
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
370
- detail: { cause: 'compiled_base_missing' }, artifact, witnessSet };
371
- }
372
- const artifactBeforeIntake = await run(
373
- 'git', ['merge-base', '--is-ancestor', artifact.base_sha, intakeBase], repoRoot, [0, 1, 128],
374
- );
375
- let comparisonBase = artifact.base_sha;
376
- let comparisonHead = intakeBase;
377
- let baseRelation = 'compiled_at_or_before_intake';
378
- if (artifactBeforeIntake.code !== 0) {
379
- const intakeBeforeArtifact = await run(
380
- 'git', ['merge-base', '--is-ancestor', intakeBase, artifact.base_sha], repoRoot, [0, 1, 128],
381
- );
382
- if (intakeBeforeArtifact.code !== 0) {
383
- return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
384
- detail: { cause: 'compiled_base_unreachable' }, artifact, witnessSet };
385
- }
386
- comparisonBase = intakeBase;
387
- comparisonHead = artifact.base_sha;
388
- baseRelation = 'compiled_after_intake';
389
- }
390
- if (![comparisonBase, comparisonHead].every(SHA1.test.bind(SHA1))) {
391
- return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
392
- detail: { cause: 'compiled_base_unreachable' }, artifact, witnessSet };
393
- }
394
- let changedPaths = [];
395
- if (comparisonBase !== comparisonHead) {
396
- const changed = await run(
397
- 'git', ['diff', '--name-only', '-z', `${comparisonBase}..${comparisonHead}`], repoRoot,
398
- );
399
- changedPaths = changed.stdout.split('\0').filter(Boolean).sort();
400
- }
401
- const intersecting = changedPaths.filter((changed) => (
402
- boundary.paths.some((declared) => pathTouches(declared, changed))
403
- ));
404
- if (intersecting.length > 0) {
405
- return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
406
- detail: { cause: 'boundary_intersecting_drift', changed_paths: intersecting }, artifact, witnessSet };
407
- }
408
- const taskUnknowns = artifact.unknowns.filter((unknown) => unknown.task_id === taskId);
409
- if (taskUnknowns.length > 0) {
410
- return { state: 'hold', reason: 'boundary_unverified', next_action: 'resolve_unknown_and_recompile',
411
- detail: { cause: 'task_boundary_unknown', unknowns: taskUnknowns }, artifact, witnessSet };
412
- }
413
- return { state: 'none', reason: null, next_action: null,
414
- detail: { compiled_base_sha: artifact.base_sha, changed_path_count: changedPaths.length,
415
- base_relation: baseRelation },
416
- artifact, witnessSet };
417
- }
418
-
419
- function buildManifest(witnessSet, taskId) {
420
- const witness = witnessSet?.manual_witness?.[taskId];
421
- if (!plain(witness)) fail('BOUNDARY_UNVERIFIED', `witnessが無い: ${taskId}`);
422
- const manifest = {
423
- schema: BOUNDARY_MANIFEST_SCHEMA,
424
- todo_id: taskId,
425
- owns: structuredClone(witness.owns),
426
- reads: structuredClone(witness.reads),
427
- writes: structuredClone(witness.writes),
428
- resources: structuredClone(witness.resources),
429
- state_effects: structuredClone(witness.state_effects),
430
- unknowns: structuredClone(witness.unknowns),
431
- affected_tests: structuredClone(witness.affected_tests),
432
- ...(witness.lines === undefined ? {} : { lines: structuredClone(witness.lines) }),
433
- graph_evidence: [],
434
- witness_provenance: Object.fromEntries(
435
- witness.resources.map((resourceId) => [resourceId, 'manual_state_effect']),
436
- ),
437
- manifest_digest: '',
438
- };
439
- manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
440
- if (!validateRuntimeBoundaryManifest(manifest)) {
441
- fail('BOUNDARY_UNVERIFIED', `runtime boundary manifestへ変換できない: ${taskId}`);
442
- }
443
- return manifest;
444
- }
445
-
446
- function planningConstraint(artifact, taskId, state, member) {
447
- const active = new Set(state.intakes.filter((entry) => entry.accepted === null).map((entry) => entry.task_id));
448
- const conflict = artifact?.conflicts?.find((entry) => (
449
- entry.task_ids.includes(taskId)
450
- && entry.task_ids.some((other) => other !== taskId && active.has(other))
451
- ));
452
- if (conflict) return { kind: 'conflict', detail: structuredClone(conflict) };
453
- const precedence = artifact?.precedences?.find((entry) => (
454
- entry.to_task_id === taskId && (() => {
455
- const predecessorIntake = state.intakes.find((intake) => (
456
- intake.task_id === entry.from_task_id
457
- ));
458
- const predecessorDone = member.tasks.find((task) => (
459
- task.task_id === entry.from_task_id
460
- ))?.status === 'done';
461
- if (predecessorIntake !== undefined) return predecessorIntake.accepted === null;
462
- return !predecessorDone;
463
- })()
464
- ));
465
- if (precedence) return { kind: 'precedence', detail: structuredClone(precedence) };
466
- return null;
467
- }
468
-
469
- function interventionPayload(verdict, constraint = null) {
470
- if (verdict.state === 'none' && constraint === null) {
471
- return { state: 'none', reason: null, next_action: null, lease_state: 'granted', detail: verdict.detail };
472
- }
473
- return {
474
- state: 'hold',
475
- reason: constraint === null ? verdict.reason
476
- : constraint.kind === 'precedence' ? 'planning_precedence' : 'planning_conflict',
477
- next_action: constraint === null ? verdict.next_action
478
- : constraint.kind === 'precedence' ? 'wait_for_predecessor_accept'
479
- : 'wait_for_prior_intake_or_resolve_seam',
480
- lease_state: 'withheld',
481
- detail: constraint === null ? verdict.detail : { [constraint.kind]: constraint.detail },
482
- };
483
- }
484
-
485
- function packetFor({ meta, taskId, baseSha, manifest }) {
486
- const packet = {
487
- schema: 'lattice.pull_executor_packet.v1',
488
- run_id: meta.run_id,
489
- todo_id: taskId,
490
- base_sha: baseSha,
491
- scope: { writes: [...manifest.writes] },
492
- manifest_digest: manifest.manifest_digest,
493
- packet_digest: '',
494
- };
495
- packet.packet_digest = selfDigest(packet, 'packet_digest');
496
- return packet;
497
- }
498
-
499
- async function acquirePullLock(runDir, operation, requestId) {
500
- return acquireRuntimeLifecycleLock({
501
- lockPath: path.join(runDir, '.pull-intake.lock'),
502
- sessionNonceDigest: digestArtifact({ run_dir: runDir, schema: PULL_RUN_META_SCHEMA }),
503
- operation,
504
- requestId,
505
- timeoutMs: 0,
506
- });
507
- }
508
-
509
- async function acquireStartBindingLock(repoRoot, operation) {
510
- return acquireRuntimeLifecycleLock({
511
- lockPath: path.join(repoRoot, '.lattice', 'todo', '.start-binding.lock'),
512
- sessionNonceDigest: digestArtifact({ repo_root: path.resolve(repoRoot), schema: PULL_RUN_META_SCHEMA }),
513
- operation,
514
- requestId: randomUUID(),
515
- timeoutMs: 0,
516
- });
517
- }
518
-
519
- async function activePullIntakesForStart(repoRoot, { planKey, taskId, activationEventDigest }) {
520
- const root = path.join(repoRoot, '.lattice', 'runs');
521
- let entries;
522
- try { entries = await readdir(root, { withFileTypes: true }); }
523
- catch (error) {
524
- if (error?.code === 'ENOENT') return [];
525
- fail('INVALID_RUN_STORE', 'run store一覧を読めない');
526
- }
527
- const active = [];
528
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
529
- if (!entry.isDirectory() || !identifier(entry.name)) {
530
- fail('INVALID_RUN_STORE', `不正なrun store entry: ${entry.name}`);
531
- }
532
- const runDir = path.join(root, entry.name);
533
- const mode = await inspectRunMode(runDir);
534
- if (mode.mode !== 'pull') continue;
535
- const stored = await readPullStore(runDir);
536
- const state = project(stored.events, stored.meta);
537
- if (state.closed || state.plan_key !== planKey) continue;
538
- const intake = state.intakes.find((candidate) => candidate.task_id === taskId
539
- && candidate.activation_event_digest === activationEventDigest
540
- && candidate.accepted === null);
541
- if (intake !== undefined) active.push({
542
- run_id: state.run_id,
543
- run_ref: `.lattice/runs/${entry.name}`,
544
- activation_event_digest: intake.activation_event_digest,
545
- });
546
- }
547
- return active;
548
- }
549
-
550
- export async function withStartRetractionGuard({
551
- repoRoot, planKey, taskId, activationEventDigest, action,
552
- }) {
553
- if (!identifier(planKey) || !identifier(taskId) || !sha256(activationEventDigest)
554
- || typeof action !== 'function') {
555
- fail('INVALID_START_RETRACTION', 'start retraction guard入力が不正');
556
- }
557
- const lock = await acquireStartBindingLock(repoRoot, 'start-retract');
558
- try {
559
- const active = await activePullIntakesForStart(repoRoot, {
560
- planKey, taskId, activationEventDigest,
561
- });
562
- if (active.length > 0) {
563
- fail('ACTIVE_PULL_INTAKE', 'active intakeをreleaseしてからstartを撤回すること', {
564
- plan_key: planKey, task_id: taskId, active_intakes: active,
565
- next_action: 'lattice run intake release --run <run_ref> --task <task_id>',
566
- });
567
- }
568
- return await action();
569
- } finally { await lock.release(); }
570
- }
571
-
572
- async function appendEvent(runDir, current, event) {
573
- const next = [...current.events, event];
574
- verifyEvents(next, current.meta);
575
- await replaceCanonical(runDir, PULL_EVENTS_FILE, next);
576
- return { meta: current.meta, events: next };
577
- }
578
-
579
- export async function startPullRun({ repoRoot, runDir, runId, planKey, equipment }) {
580
- if (![repoRoot, runDir].every((value) => typeof value === 'string' && path.isAbsolute(value))
581
- || !identifier(runId) || !identifier(planKey) || equipment !== 'detached-worktree') {
582
- fail('INVALID_PULL_START', 'pull start入力が不正');
583
- }
584
- const temporary = await mkdtemp(path.join(path.dirname(runDir), `.${runId}.pull-tmp-`));
585
- const meta = {
586
- schema: PULL_RUN_META_SCHEMA,
587
- run_id: runId,
588
- plan_key: planKey,
589
- selection: 'pull',
590
- equipment,
591
- created_at: now(),
592
- meta_digest: '',
593
- };
594
- meta.meta_digest = selfDigest(meta, 'meta_digest');
595
- const events = [buildEvent({
596
- events: [], meta, kind: 'run_started',
597
- payload: { selection: 'pull', equipment, plan_key: planKey },
598
- })];
599
- try {
600
- try {
601
- await lstat(runDir);
602
- fail('RUN_EXISTS', 'run storeが既に存在する');
603
- } catch (error) {
604
- if (error instanceof PullRunError) throw error;
605
- if (error?.code !== 'ENOENT') throw error;
606
- }
607
- await writeCanonicalNew(path.join(temporary, 'run-meta.json'), meta);
608
- await writeCanonicalNew(path.join(temporary, PULL_EVENTS_FILE), events);
609
- await rename(temporary, runDir);
610
- } catch (error) {
611
- await rm(temporary, { recursive: true, force: true });
612
- throw error;
613
- }
614
- return {
615
- schema: 'lattice.pull_run_start_result.v1',
616
- outcome: 'started', run_id: runId, plan_key: planKey,
617
- selection: 'pull', equipment, intake_count: 0,
618
- };
619
- }
620
-
621
- export async function intakePullTask({ repoRoot, runDir, taskId, environment = process.env }) {
622
- if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
623
- const actor = actorFromEnvironment(environment);
624
- const lock = await acquirePullLock(runDir, 'intake', `${taskId}-${randomUUID()}`);
625
- try {
626
- let current = await readPullStore(runDir);
627
- let state = project(current.events, current.meta);
628
- if (state.closed) fail('RUN_CLOSED', 'closed pull runへintakeできない');
629
- const existing = state.intakes.find((entry) => entry.task_id === taskId);
630
- if (existing !== undefined) {
631
- if (!sameActor(existing.actor, actor)) {
632
- fail('INTAKE_BINDING_CONFLICT', '同taskを別actor bindingでintakeできない');
633
- }
634
- const store = await readTodoStore({ repoRoot });
635
- const member = activeMember(store, current.meta.plan_key);
636
- const activation = literalEvent(member, { kind: 'start', taskId, actor });
637
- if (member.plan.plan_version !== existing.plan_version
638
- || member.plan.topology_digest !== existing.topology_digest
639
- || activation?.event_digest !== existing.activation_event_digest) {
640
- fail('INTAKE_BINDING_CONFLICT', '既存intakeのactivation/version bindingと一致しない');
641
- }
642
- if (existing.accepted !== null) {
643
- const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
644
- already_intaked: true, refreshed: false, ...publicIntake(existing) };
645
- result.result_digest = digestArtifact(result);
646
- return result;
647
- }
648
-
649
- const verdict = await boundaryVerdict({
650
- repoRoot, store, member, meta: current.meta, taskId, intakeBase: existing.base_sha,
651
- });
652
- let manifest;
653
- try { manifest = buildManifest(verdict.witnessSet, taskId); }
654
- catch (error) {
655
- if (!(error instanceof PullRunError)) throw error;
656
- manifest = {
657
- schema: BOUNDARY_MANIFEST_SCHEMA, todo_id: taskId,
658
- owns: [], reads: [], writes: [], resources: [], state_effects: [], unknowns: [],
659
- affected_tests: [], graph_evidence: [], witness_provenance: {}, manifest_digest: '',
660
- };
661
- manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
662
- verdict.state = 'hold'; verdict.reason = 'boundary_unverified';
663
- verdict.next_action = 'declare_and_recompile'; verdict.detail = { cause: error.code };
664
- }
665
- const constraint = verdict.state === 'none'
666
- ? planningConstraint(verdict.artifact, taskId, state, member) : null;
667
- const intervention = interventionPayload(verdict, constraint);
668
-
669
- const confirmation = await readTodoStore({ repoRoot });
670
- const confirmedMember = activeMember(confirmation, current.meta.plan_key);
671
- const confirmedActivation = literalEvent(confirmedMember, { kind: 'start', taskId, actor });
672
- if (confirmedMember.plan.plan_version !== member.plan.plan_version
673
- || confirmedMember.plan.topology_digest !== member.plan.topology_digest
674
- || confirmedActivation?.event_digest !== activation.event_digest) {
675
- intervention.state = 'hold'; intervention.reason = 'version_drift';
676
- intervention.next_action = 'retry_intake_on_active_version'; intervention.lease_state = 'withheld';
677
- intervention.detail = { cause: 'todo_binding_changed_during_intake', equipment_state: 'isolated' };
678
- }
679
- // packet/worktreeは初回intakeのequipment identityとして不変にする。
680
- // 再compile後の実効境界はmanifestと介入へ投影し、attach済みhold workerも同じ設備で復旧する。
681
- const refresh = {
682
- manifest,
683
- independence_result_digest: verdict.artifact?.result_digest ?? null,
684
- witness_set_digest: verdict.witnessSet?.witness_set_digest ?? null,
685
- intervention,
686
- };
687
- const previous = {
688
- manifest: existing.manifest,
689
- independence_result_digest: existing.independence_result_digest,
690
- witness_set_digest: existing.witness_set_digest,
691
- intervention: existing.intervention,
692
- };
693
- const refreshed = digestArtifact(refresh) !== digestArtifact(previous);
694
- if (refreshed) {
695
- current = await appendEvent(runDir, current, buildEvent({
696
- events: current.events, meta: current.meta, kind: 'intake_refreshed', taskId,
697
- payload: refresh,
698
- }));
699
- }
700
- const updated = project(current.events, current.meta).intakes
701
- .find((entry) => entry.task_id === taskId);
702
- if (updated.worker && intervention.state === 'hold' && !updated.worker.stopped) {
703
- await signalAttachedWorker(updated, 'SIGSTOP');
704
- current = await appendEvent(runDir, current, buildEvent({
705
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
706
- payload: { reason: intervention.reason },
707
- }));
708
- } else if (updated.worker?.stopped && intervention.state === 'none') {
709
- await signalAttachedWorker(updated, 'SIGCONT');
710
- current = await appendEvent(runDir, current, buildEvent({
711
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
712
- payload: { released_by: 'intake_refresh' },
713
- }));
714
- }
715
- state = project(current.events, current.meta);
716
- const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
717
- already_intaked: true, refreshed,
718
- ...publicIntake(state.intakes.find((entry) => entry.task_id === taskId)) };
719
- result.result_digest = digestArtifact(result);
720
- return result;
721
- }
722
-
723
- const bindingLock = await acquireStartBindingLock(repoRoot, 'intake-bind');
724
- try {
725
- const store = await readTodoStore({ repoRoot });
726
- const { member, activation } = resolveStartBinding(store, current.meta, taskId, actor);
727
- const baseSha = await headSha(repoRoot);
728
- const verdict = await boundaryVerdict({
729
- repoRoot, store, member, meta: current.meta, taskId, intakeBase: baseSha,
730
- });
731
- let manifest;
732
- try { manifest = buildManifest(verdict.witnessSet, taskId); }
733
- catch (error) {
734
- if (!(error instanceof PullRunError)) throw error;
735
- manifest = {
736
- schema: BOUNDARY_MANIFEST_SCHEMA, todo_id: taskId,
737
- owns: [], reads: [], writes: [], resources: [], state_effects: [], unknowns: [],
738
- affected_tests: [], graph_evidence: [], witness_provenance: {}, manifest_digest: '',
739
- };
740
- manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
741
- verdict.state = 'hold'; verdict.reason = 'boundary_unverified';
742
- verdict.next_action = 'declare_and_recompile'; verdict.detail = { cause: error.code };
743
- }
744
- const packet = packetFor({ meta: current.meta, taskId, baseSha, manifest });
745
- const worktreePath = await ensureScriptedWorktree({ repoRoot, runDir, packet });
746
- const firstVersion = state.plan_version;
747
- if (firstVersion !== null && firstVersion !== member.plan.plan_version) {
748
- verdict.state = 'hold'; verdict.reason = 'version_drift'; verdict.next_action = 'close_or_rebase_run';
749
- verdict.detail = { cause: 'run_plan_version_mismatch', run_version: firstVersion,
750
- task_version: member.plan.plan_version };
751
- }
752
- const constraint = verdict.state === 'none'
753
- ? planningConstraint(verdict.artifact, taskId, state, member) : null;
754
- const intervention = interventionPayload(verdict, constraint);
755
-
756
- // retract側と同じrepo-wide lock内でstart bindingを再確認してから記録する。
757
- // 先にretractされたら確認がtyped failし、先にintakeを記録したらretract側の走査が拒否する。
758
- const confirmation = await readTodoStore({ repoRoot });
759
- const confirmed = resolveStartBinding(confirmation, current.meta, taskId, actor);
760
- if (confirmed.member.plan.plan_version !== member.plan.plan_version
761
- || confirmed.member.plan.topology_digest !== member.plan.topology_digest
762
- || confirmed.activation.event_digest !== activation.event_digest) {
763
- intervention.state = 'hold'; intervention.reason = 'version_drift';
764
- intervention.next_action = 'retry_intake_on_active_version'; intervention.lease_state = 'withheld';
765
- intervention.detail = { cause: 'todo_binding_changed_during_intake', equipment_state: 'isolated' };
766
- }
767
- const payload = {
768
- actor, project_id: store.project_id,
769
- plan_version: member.plan.plan_version,
770
- topology_digest: member.plan.topology_digest,
771
- activation_event_digest: activation.event_digest,
772
- base_sha: baseSha,
773
- worktree_path: worktreePath,
774
- packet,
775
- manifest,
776
- independence_result_digest: verdict.artifact?.result_digest ?? null,
777
- witness_set_digest: verdict.witnessSet?.witness_set_digest ?? null,
778
- intervention,
779
- };
780
- current = await appendEvent(runDir, current, buildEvent({
781
- events: current.events, meta: current.meta, kind: 'intake_recorded', taskId, payload,
782
- }));
783
- const intake = project(current.events, current.meta).intakes.find((entry) => entry.task_id === taskId);
784
- const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
785
- already_intaked: false, refreshed: false, ...publicIntake(intake) };
786
- result.result_digest = digestArtifact(result);
787
- return result;
788
- } finally { await bindingLock.release(); }
789
- } finally { await lock.release(); }
790
- }
791
-
792
- export async function releasePullTask({ runDir, taskId, environment = process.env }) {
793
- if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
794
- const actor = actorFromEnvironment(environment);
795
- const lock = await acquirePullLock(runDir, 'release', `release-${randomUUID()}`);
796
- try {
797
- let current = await readPullStore(runDir);
798
- const state = project(current.events, current.meta);
799
- if (state.closed) fail('RUN_CLOSED', 'closed pull runのintakeをreleaseできない');
800
- const intake = state.intakes.find((entry) => entry.task_id === taskId);
801
- if (intake === undefined) fail('INTAKE_NOT_FOUND', 'active intakeが存在しない', { task_id: taskId });
802
- if (intake.accepted !== null) {
803
- fail('INTAKE_ALREADY_ACCEPTED', 'accepted intakeはreleaseできない', { task_id: taskId });
804
- }
805
- if (intake.worker !== null) {
806
- fail('INTAKE_WORKER_ATTACHED', 'workerがattach済みのintakeはreleaseできない', {
807
- task_id: taskId,
808
- next_action: 'workerを安全に停止・detachできる正規経路を先に使う',
809
- });
810
- }
811
- if (!sameActor(intake.actor, actor)) {
812
- fail('INTAKE_BINDING_CONFLICT', 'intakeを作成したactorだけがreleaseできる', {
813
- task_id: taskId,
814
- });
815
- }
816
- current = await appendEvent(runDir, current, buildEvent({
817
- events: current.events, meta: current.meta, kind: 'intake_released', taskId,
818
- payload: { released_by: actor },
819
- }));
820
- const output = {
821
- schema: 'lattice.pull_intake_release_result.v1', outcome: 'released',
822
- run_id: state.run_id, plan_key: state.plan_key, task_id: taskId,
823
- release_event_digest: current.events.at(-1).event_digest, result_digest: '',
824
- };
825
- output.result_digest = digestArtifact(output);
826
- return output;
827
- } finally { await lock.release(); }
828
- }
829
-
830
- async function observeProcessBinding(pid) {
831
- if (!Number.isSafeInteger(pid) || pid < 1) fail('INVALID_WORKER_PID', 'pidが正整数でない');
832
- const processStartIdentity = await observeManagedProcessStartIdentity(pid);
833
- const { stdout: pgidOut } = await execFileAsync('/bin/ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
834
- const { stdout: argvOut } = await execFileAsync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
835
- const processGroupId = Number(pgidOut.trim());
836
- const argv = argvOut.trim();
837
- const expected = { pid, process_group_id: processGroupId, process_start_identity: processStartIdentity };
838
- if (!validateExpectedWorkerProcess(expected) || argv.length === 0) {
839
- fail('WORKER_IDENTITY_MISMATCH', 'worker process identityを完全観測できない');
840
- }
841
- return {
842
- pid, process_group_id: processGroupId,
843
- process_start_identity: processStartIdentity,
844
- argv_digest: createHash('sha256').update(argv, 'utf8').digest('hex'),
845
- };
846
- }
847
-
848
- async function signalAttachedWorker(intake, signal) {
849
- if (intake.worker === null) return false;
850
- let observed;
851
- try { observed = await observeProcessBinding(intake.worker.pid); }
852
- catch { fail('WORKER_IDENTITY_MISMATCH', 'signal前にworkerを再認証できない'); }
853
- if (observed.process_start_identity.identity_digest
854
- !== intake.worker.process_start_identity.identity_digest
855
- || observed.argv_digest !== intake.worker.argv_digest
856
- || observed.process_group_id !== intake.worker.process_group_id) {
857
- fail('WORKER_IDENTITY_MISMATCH', 'signal前のlstart/argv/pgidがattach bindingと一致しない');
858
- }
859
- try { process.kill(intake.worker.pid, signal); }
860
- catch { fail('WORKER_SIGNAL_FAILED', `workerへ${signal}を送れない`); }
861
- return true;
862
- }
863
-
864
- function validateAttachInput(input) {
865
- if (!exact(input, [
866
- 'schema', 'name', 'session', 'pid', 'started_identity', 'argv_digest', 'recorded_at',
867
- ])
868
- || input.schema !== 'lattice.pull_worker_attach_input.v1'
869
- || !identifier(input.name) || !identifier(input.session)
870
- || !Number.isSafeInteger(input.pid) || input.pid < 1
871
- || typeof input.started_identity !== 'string' || input.started_identity.length < 1
872
- || input.started_identity.length > 512
873
- || !sha256(input.argv_digest)
874
- || !timestamp(input.recorded_at)) {
875
- fail('INVALID_WORKER_ATTACH_INPUT', '生のexpected start identityとargv digestを持つattach inputが不正');
876
- }
877
- }
878
-
879
- export async function attachPullWorker({ runDir, taskId, input, environment = process.env }) {
880
- validateAttachInput(input);
881
- const actor = actorFromEnvironment(environment);
882
- const lock = await acquirePullLock(runDir, 'attach', `${taskId}-${randomUUID()}`);
883
- try {
884
- let current = await readPullStore(runDir);
885
- let state = project(current.events, current.meta);
886
- const intake = state.intakes.find((entry) => entry.task_id === taskId);
887
- if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
888
- if (!sameActor(intake.actor, actor)) fail('WORKER_ACTOR_MISMATCH', 'start/intake actorだけがattachできる');
889
- if (input.name !== actor.agent || input.session !== actor.session) {
890
- fail('WORKER_ACTOR_MISMATCH', 'attach inputの席identityがTodo actorと一致しない');
891
- }
892
- const binding = await observeProcessBinding(input.pid);
893
- if (binding.process_group_id !== input.pid
894
- || binding.process_start_identity.started_identity !== input.started_identity
895
- || binding.argv_digest !== input.argv_digest) {
896
- fail('WORKER_IDENTITY_MISMATCH', 'expected pid/lstart/argvと現在のOS観測が一致しない');
897
- }
898
- const occupied = state.intakes.find((entry) => (
899
- entry.task_id !== taskId && entry.accepted === null && entry.worker?.pid === input.pid
900
- ));
901
- if (occupied) {
902
- fail('WORKER_ALREADY_ATTACHED', `同じworkerは複数active intakeへattachできない: ${occupied.task_id}`);
903
- }
904
- if (intake.worker !== null) {
905
- if (digestArtifact(binding) !== digestArtifact({
906
- pid: intake.worker.pid, process_group_id: intake.worker.process_group_id,
907
- process_start_identity: intake.worker.process_start_identity,
908
- argv_digest: intake.worker.argv_digest,
909
- })) fail('WORKER_BINDING_CONFLICT', '同taskへ別worker identityをattachできない');
910
- } else {
911
- current = await appendEvent(runDir, current, buildEvent({
912
- events: current.events, meta: current.meta, kind: 'worker_attached', taskId,
913
- payload: binding,
914
- }));
915
- state = project(current.events, current.meta);
916
- }
917
- const refreshed = state.intakes.find((entry) => entry.task_id === taskId);
918
- if (refreshed.intervention.state === 'hold' && !refreshed.worker.stopped) {
919
- await signalAttachedWorker(refreshed, 'SIGSTOP');
920
- current = await appendEvent(runDir, current, buildEvent({
921
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
922
- payload: { reason: refreshed.intervention.reason },
923
- }));
924
- }
925
- const result = { schema: 'lattice.pull_worker_attach_result.v1', outcome: 'attached',
926
- task_id: taskId, pid: input.pid, stopped: project(current.events, current.meta)
927
- .intakes.find((entry) => entry.task_id === taskId).worker.stopped };
928
- result.result_digest = digestArtifact(result);
929
- return result;
930
- } finally { await lock.release(); }
931
- }
932
-
933
- function observationModel(state) {
934
- const intakes = state.intakes.filter((entry) => (
935
- entry.accepted === null && entry.intervention.state !== 'hold'
936
- ));
937
- const manifests = Object.fromEntries(intakes.map((entry) => [entry.task_id, entry.manifest]));
938
- const nodes = intakes.map((entry) => ({ todo_id: entry.task_id })).sort((l, r) => l.todo_id.localeCompare(r.todo_id));
939
- const plan = {
940
- schema: 'lattice.runtime_plan.v1',
941
- plan_ref: `pull-${state.run_id}-observation`, plan_epoch: 1,
942
- request_digest: digestArtifact({ run_id: state.run_id, task_ids: nodes.map((entry) => entry.todo_id) }),
943
- base_sha: intakes[0]?.base_sha ?? '0'.repeat(40),
944
- nodes, precedence: [], conflicts: [], capacity: { executors: Math.max(1, intakes.length) },
945
- manifest_digests: Object.fromEntries(nodes.map(({ todo_id: taskId }) => (
946
- [taskId, manifests[taskId].manifest_digest]
947
- ))),
948
- claim: { mode: 'exact_minimum' }, predecessor_refs: [], plan_digest: '',
949
- };
950
- plan.plan_digest = selfDigest(plan, 'plan_digest');
951
- if (!validateRuntimePlan(plan)) fail('CONTRACT_VIOLATION', 'pull observation planを構成できない');
952
- return { intakes, manifests, plan };
953
- }
954
-
955
- function doneBinding(store, intake) {
956
- const member = activeMember(store, intake.plan_key ?? store.members
957
- .find((entry) => entry.plan.plan_version === intake.plan_version)?.plan.plan_key);
958
- if (member.plan.plan_version !== intake.plan_version
959
- || member.plan.topology_digest !== intake.topology_digest) {
960
- fail('VERSION_DRIFT', 'accept時にplan version/topologyが変わっている');
961
- }
962
- const task = member.tasks.find((entry) => entry.task_id === intake.task_id);
963
- if (task?.status !== 'done') fail('TASK_NOT_DONE', `todo done前はacceptできない: ${intake.task_id}`);
964
- const event = literalEvent(member, { kind: 'done', taskId: intake.task_id, actor: intake.actor });
965
- if (event === null) fail('TASK_DONE_BINDING_UNSUPPORTED', 'same-version literal authored doneだけをacceptする');
966
- return event;
967
- }
968
-
969
- async function changeIntervention(runDir, current, taskId, intervention) {
970
- return appendEvent(runDir, current, buildEvent({
971
- events: current.events, meta: current.meta, kind: 'intervention_changed', taskId,
972
- payload: { intervention },
973
- }));
974
- }
975
-
976
- export async function acceptPullTask({ repoRoot, runDir, taskId, environment = process.env }) {
977
- const actor = actorFromEnvironment(environment);
978
- const lock = await acquirePullLock(runDir, 'accept', `${taskId}-${randomUUID()}`);
979
- try {
980
- let current = await readPullStore(runDir);
981
- let state = project(current.events, current.meta);
982
- const intake = state.intakes.find((entry) => entry.task_id === taskId);
983
- if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
984
- if (!sameActor(intake.actor, actor)) fail('WORKER_ACTOR_MISMATCH', 'intake actorだけがacceptできる');
985
- if (intake.accepted !== null) {
986
- const result = { schema: 'lattice.pull_accept_result.v1', outcome: 'accepted',
987
- already_accepted: true, task_id: taskId, head_sha: intake.accepted.head_sha,
988
- done_event_digest: intake.accepted.done_event_digest };
989
- result.result_digest = digestArtifact(result); return result;
990
- }
991
- if (intake.intervention.state === 'hold') {
992
- fail('TASK_HELD', 'hold中taskはacceptできない', {
993
- reason: intake.intervention.reason, next_action: intake.intervention.next_action,
994
- });
995
- }
996
- const store = await readTodoStore({ repoRoot });
997
- const done = doneBinding(store, { ...intake, plan_key: current.meta.plan_key });
998
- const checkpoint = await captureWorktreeDiff({
999
- worktreePath: intake.worktree_path, baseSha: intake.base_sha,
1000
- });
1001
- const model = observationModel(state);
1002
- const packets = Object.fromEntries(model.intakes.map((entry) => [entry.task_id, entry.packet]));
1003
- const direct = detectCheckpointFindings({
1004
- todoId: taskId, checkpoint, packets, manifests: model.manifests,
1005
- runningTodoIds: model.intakes.map((entry) => entry.task_id),
1006
- }).findings;
1007
- const observations = [];
1008
- for (const entry of model.intakes) {
1009
- if (entry.task_id === taskId) {
1010
- observations.push({ todo_id: taskId, paths: checkpoint.diff.entries.map((item) => item.path) });
1011
- } else if (entry.last_checkpoint?.diff?.entries) {
1012
- observations.push({ todo_id: entry.task_id,
1013
- paths: entry.last_checkpoint.diff.entries.map((item) => item.path) });
1014
- }
1015
- }
1016
- const independent = classifyObservedDiff({
1017
- plan: model.plan, manifests: model.manifests, observations,
1018
- relevantTodoIds: model.intakes.map((entry) => entry.task_id),
1019
- }).findings;
1020
- const findings = [...direct, ...independent].filter((finding, index, all) => (
1021
- index === all.findIndex((candidate) => digestArtifact(candidate) === digestArtifact(finding))
1022
- ));
1023
- if (findings.length > 0) {
1024
- const affected = new Set(findings.flatMap((finding) => finding.todo_ids));
1025
- for (const affectedTaskId of [...affected].sort()) {
1026
- const affectedIntake = project(current.events, current.meta).intakes
1027
- .find((entry) => entry.task_id === affectedTaskId);
1028
- if (!affectedIntake || affectedIntake.accepted !== null) continue;
1029
- const intervention = { state: 'hold', reason: 'runtime_conflict',
1030
- next_action: 'inspect_findings_and_resolve_or_split', lease_state: 'revoked',
1031
- detail: { findings: findings.filter((finding) => finding.todo_ids.includes(affectedTaskId)) } };
1032
- current = await changeIntervention(runDir, current, affectedTaskId, intervention);
1033
- const stoppedIntake = project(current.events, current.meta).intakes
1034
- .find((entry) => entry.task_id === affectedTaskId);
1035
- if (stoppedIntake.worker && !stoppedIntake.worker.stopped) {
1036
- await signalAttachedWorker(stoppedIntake, 'SIGSTOP');
1037
- current = await appendEvent(runDir, current, buildEvent({
1038
- events: current.events, meta: current.meta, kind: 'worker_stopped', taskId: affectedTaskId,
1039
- payload: { reason: 'runtime_conflict' },
1040
- }));
1041
- }
1042
- }
1043
- fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings });
1044
- }
1045
- current = await appendEvent(runDir, current, buildEvent({
1046
- events: current.events, meta: current.meta, kind: 'task_accepted', taskId,
1047
- payload: { done_event_digest: done.event_digest,
1048
- checkpoint_digest: checkpoint.checkpoint_digest, checkpoint,
1049
- head_sha: checkpoint.diff.head_sha },
1050
- }));
1051
-
1052
- // 先着taskがacceptedになった後、planning conflictだけで待っていた後着を再投影する。
1053
- state = project(current.events, current.meta);
1054
- const planningMember = activeMember(store, current.meta.plan_key);
1055
- for (const waiting of state.intakes.filter((entry) => (
1056
- entry.accepted === null
1057
- && ['planning_conflict', 'planning_precedence'].includes(entry.intervention.reason)
1058
- ))) {
1059
- const artifact = await readTodoIndependenceArtifact({ repoRoot, planKey: current.meta.plan_key });
1060
- if (planningConstraint(artifact, waiting.task_id, state, planningMember) !== null) continue;
1061
- const intervention = { state: 'none', reason: null, next_action: null,
1062
- lease_state: 'granted', detail: { released_by_accepted_task: taskId } };
1063
- current = await changeIntervention(runDir, current, waiting.task_id, intervention);
1064
- const resumed = project(current.events, current.meta).intakes
1065
- .find((entry) => entry.task_id === waiting.task_id);
1066
- if (resumed.worker?.stopped) {
1067
- await signalAttachedWorker(resumed, 'SIGCONT');
1068
- current = await appendEvent(runDir, current, buildEvent({
1069
- events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1070
- payload: { released_by_accepted_task: taskId },
1071
- }));
1072
- }
1073
- }
1074
- const result = { schema: 'lattice.pull_accept_result.v1', outcome: 'accepted',
1075
- already_accepted: false, task_id: taskId, head_sha: checkpoint.diff.head_sha,
1076
- done_event_digest: done.event_digest, checkpoint_digest: checkpoint.checkpoint_digest };
1077
- result.result_digest = digestArtifact(result); return result;
1078
- } finally { await lock.release(); }
1079
- }
1080
-
1081
- export async function observePullRun(runDir) {
1082
- const stored = await readPullStore(runDir);
1083
- const state = project(stored.events, stored.meta);
1084
- const output = {
1085
- schema: 'lattice.pull_run_observation.v1', run_id: state.run_id,
1086
- plan_key: state.plan_key, plan_version: state.plan_version,
1087
- intakes: state.intakes.map(publicIntake),
1088
- hold_count: state.intakes.filter((entry) => entry.intervention.state === 'hold').length,
1089
- accepted_count: state.intakes.filter((entry) => entry.accepted !== null).length,
1090
- closed: state.closed, event_count: stored.events.length,
1091
- events_digest: digestArtifact(stored.events.map((event) => event.event_digest)),
1092
- };
1093
- output.result_digest = digestArtifact(output); return output;
1094
- }
1095
-
1096
- export async function statusPullRun(runDir) {
1097
- const observed = await observePullRun(runDir);
1098
- const output = { ...observed, schema: 'lattice.pull_run_status.v1' };
1099
- output.result_digest = digestArtifact(output); return output;
1100
- }
1101
-
1102
- export async function interventionPullTask(runDir, taskId) {
1103
- const stored = await readPullStore(runDir);
1104
- const intake = project(stored.events, stored.meta).intakes.find((entry) => entry.task_id === taskId);
1105
- if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
1106
- const output = { schema: 'lattice.pull_intervention.v1', run_id: stored.meta.run_id,
1107
- task_id: taskId, ...structuredClone(intake.intervention),
1108
- worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false };
1109
- output.result_digest = digestArtifact(output); return output;
1110
- }
1111
-
1112
- async function repositoryLanding(repoRoot, acceptedReceipts) {
1113
- const push = await run('git', ['rev-parse', '--symbolic-full-name', '@{push}'], repoRoot, [0, 128]);
1114
- let pushState = 'no_upstream'; let pushRef = null; let unpushedCommits = null;
1115
- if (push.code === 0 && push.stdout.trim()) {
1116
- pushRef = push.stdout.trim(); pushState = 'tracked';
1117
- const count = await run('git', ['rev-list', '--count', `${pushRef}..HEAD`], repoRoot);
1118
- unpushedCommits = Number(count.stdout.trim());
1119
- }
1120
- const remote = await run('git', ['remote'], repoRoot);
1121
- const remoteNames = remote.stdout.split('\n').filter(Boolean);
1122
- let defaultBranchRef = null;
1123
- if (remoteNames.length === 1) {
1124
- const symbolic = await run('git', ['symbolic-ref', '--quiet', `refs/remotes/${remoteNames[0]}/HEAD`], repoRoot, [0, 1]);
1125
- if (symbolic.code === 0) defaultBranchRef = symbolic.stdout.trim();
1126
- }
1127
- const receipts = [];
1128
- for (const receipt of acceptedReceipts) {
1129
- let landingState = 'default_branch_unresolved'; let landed = false;
1130
- if (defaultBranchRef !== null) {
1131
- const ancestry = await run('git', ['merge-base', '--is-ancestor', receipt.head_sha, defaultBranchRef], repoRoot, [0, 1]);
1132
- landed = ancestry.code === 0; landingState = landed ? 'landed' : 'not_landed';
1133
- }
1134
- receipts.push({ ...receipt, landing_state: landingState, landed });
1135
- }
1136
- return { receipts, repository: {
1137
- default_branch_state: defaultBranchRef === null ? 'unresolved' : 'resolved',
1138
- default_branch_ref: defaultBranchRef, push_state: pushState, push_ref: pushRef, unpushed_commits: unpushedCommits,
1139
- } };
1140
- }
1141
-
1142
- export async function landingPullRun({ repoRoot, runDir }) {
1143
- const stored = await readPullStore(runDir);
1144
- const state = project(stored.events, stored.meta);
1145
- const accepted = state.intakes.filter((entry) => entry.accepted !== null).map((entry) => ({
1146
- todo_id: entry.task_id,
1147
- receipt_id: entry.accepted.done_event_digest,
1148
- head_sha: entry.accepted.head_sha,
1149
- }));
1150
- const { receipts, repository } = await repositoryLanding(repoRoot, accepted);
1151
- const output = { schema: 'lattice.run_landing_report.v1', run_id: state.run_id,
1152
- landed: receipts.length > 0 && receipts.every((entry) => entry.landed),
1153
- accepted_receipts: receipts, repository };
1154
- output.result_digest = digestArtifact(output); return output;
1155
- }
1156
-
1157
- export async function closePullRun({ repoRoot, runDir }) {
1158
- const lock = await acquirePullLock(runDir, 'close', `close-${randomUUID()}`);
1159
- try {
1160
- let current = await readPullStore(runDir);
1161
- const state = project(current.events, current.meta);
1162
- if (state.closed) {
1163
- const output = { schema: 'lattice.pull_run_close_result.v1', outcome: 'closed',
1164
- run_id: state.run_id, already_closed: true, intake_count: state.intakes.length,
1165
- landing: await landingPullRun({ repoRoot, runDir }) };
1166
- output.result_digest = digestArtifact(output); return output;
1167
- }
1168
- const incomplete = state.intakes.filter((entry) => entry.accepted === null);
1169
- if (incomplete.length > 0) {
1170
- fail('RUN_NOT_COMPLETE', 'pending/hold中intakeが残っている', {
1171
- task_ids: incomplete.map((entry) => entry.task_id),
1172
- });
1173
- }
1174
- current = await appendEvent(runDir, current, buildEvent({
1175
- events: current.events, meta: current.meta, kind: 'run_closed',
1176
- payload: { accepted_task_ids: state.intakes.map((entry) => entry.task_id).sort() },
1177
- }));
1178
- const output = { schema: 'lattice.pull_run_close_result.v1', outcome: 'closed',
1179
- run_id: state.run_id, already_closed: false, intake_count: state.intakes.length,
1180
- landing: await landingPullRun({ repoRoot, runDir }) };
1181
- output.result_digest = digestArtifact(output); return output;
1182
- } finally { await lock.release(); }
1183
- }
1184
-
1185
- export async function listPullRunEntry(runDir) {
1186
- const stored = await readPullStore(runDir);
1187
- const state = project(stored.events, stored.meta);
1188
- return { closed: state.closed, entry: {
1189
- run_id: state.run_id, run_ref: null, base_sha: null,
1190
- executor_adapter: null, selection: 'pull', plan_key: state.plan_key,
1191
- } };
1192
- }
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import {
5
+ lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile,
6
+ } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { promisify } from 'node:util';
9
+
10
+ import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
11
+ import { validateExpectedWorkerProcess } from './runtime-controller-protocol.mjs';
12
+ import { classifyObservedDiff } from './runtime-decision-verifier.mjs';
13
+ import { captureWorktreeDiff, detectCheckpointFindings } from './runtime-diff-observer.mjs';
14
+ import { acquireRuntimeLifecycleLock } from './runtime-lifecycle-lock.mjs';
15
+ import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
16
+ import { ensureScriptedWorktree } from './runtime-scripted-worktree.mjs';
17
+ import {
18
+ BOUNDARY_MANIFEST_SCHEMA, selfDigest, validateRuntimeBoundaryManifest, validateRuntimePlan,
19
+ } from './runtime-contracts.mjs';
20
+ import { TODO_INDEPENDENCE_SCHEMA } from './todo-independence-contracts.mjs';
21
+ import {
22
+ readTodoIndependenceArtifact, readTodoStore, readTodoWitnessSet,
23
+ } from './todo-store.mjs';
24
+
25
+ export const PULL_RUN_META_SCHEMA = 'lattice.pull_run_meta.v1';
26
+ const PULL_EVENT_SCHEMA = 'lattice.pull_run_event.v1';
27
+ const PULL_EVENTS_FILE = 'pull-events.json';
28
+ const LEGACY_META_SCHEMAS = new Set(['lattice.run_meta.v1', 'lattice.run_meta.v2']);
29
+ const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
30
+ const SHA1 = /^[0-9a-f]{40}$/u;
31
+ const SHA256 = /^[0-9a-f]{64}$/u;
32
+ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
33
+ const MAX_FILE_BYTES = 8_388_608;
34
+ const execFileAsync = promisify(execFile);
35
+
36
+ export class PullRunError extends Error {
37
+ constructor(code, message, detail) {
38
+ super(message);
39
+ this.name = 'PullRunError';
40
+ this.code = code;
41
+ this.detail = detail;
42
+ }
43
+ }
44
+
45
+ function fail(code, message, detail) { throw new PullRunError(code, message, detail); }
46
+ function now() { return new Date().toISOString(); }
47
+ function plain(value) {
48
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
49
+ && Object.getPrototypeOf(value) === Object.prototype;
50
+ }
51
+ function exact(value, keys) {
52
+ return plain(value) && Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
53
+ }
54
+ function identifier(value) { return typeof value === 'string' && ID.test(value); }
55
+ function sha256(value) { return typeof value === 'string' && SHA256.test(value); }
56
+ function timestamp(value) {
57
+ return typeof value === 'string' && TIMESTAMP.test(value)
58
+ && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
59
+ }
60
+
61
+ function run(command, args, cwd, allowExitCodes = [0]) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
64
+ const stdout = [];
65
+ const stderr = [];
66
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
67
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
68
+ child.once('error', reject);
69
+ child.once('close', (code, signal) => {
70
+ const output = {
71
+ code, signal,
72
+ stdout: Buffer.concat(stdout).toString('utf8'),
73
+ stderr: Buffer.concat(stderr).toString('utf8'),
74
+ };
75
+ if (signal === null && allowExitCodes.includes(code)) resolve(output);
76
+ else reject(new PullRunError(
77
+ 'GIT_OBSERVATION_FAILED', `${command} ${args.join(' ')} failed`, output,
78
+ ));
79
+ });
80
+ });
81
+ }
82
+
83
+ async function readJson(filePath, label) {
84
+ let info;
85
+ try { info = await lstat(filePath); }
86
+ catch { fail('INVALID_RUN_STORE', `${label}を読めない`); }
87
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES) {
88
+ fail('INVALID_RUN_STORE', `${label}が安全なbounded regular fileでない`);
89
+ }
90
+ let value;
91
+ try { value = JSON.parse(await readFile(filePath, 'utf8')); }
92
+ catch { fail('INVALID_RUN_STORE', `${label}がJSONとして不正`); }
93
+ return value;
94
+ }
95
+
96
+ async function syncDirectory(directory) {
97
+ const handle = await open(directory, fsConstants.O_RDONLY);
98
+ // Windowsはdirectory handleのfsyncを許さず常にEPERM/EINVALを返す(Node仕様)。
99
+ // win32のこの2値だけ許容し、他OS・他エラーは従来どおり失敗させる。
100
+ try { await handle.sync(); } catch (error) {
101
+ if (process.platform !== 'win32' || !['EPERM', 'EINVAL'].includes(error?.code)) throw error;
102
+ } finally { await handle.close(); }
103
+ }
104
+
105
+ async function writeCanonicalNew(filePath, value) {
106
+ await writeFile(filePath, `${canonicalizeArtifact(value)}\n`, { mode: 0o600, flag: 'wx' });
107
+ }
108
+
109
+ async function replaceCanonical(runDir, name, value) {
110
+ const temporary = path.join(runDir, `.${name}-${process.pid}-${randomUUID()}.tmp`);
111
+ let handle;
112
+ try {
113
+ handle = await open(temporary,
114
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
115
+ await handle.writeFile(Buffer.from(`${canonicalizeArtifact(value)}\n`));
116
+ await handle.sync();
117
+ await handle.close();
118
+ handle = null;
119
+ await rename(temporary, path.join(runDir, name));
120
+ await syncDirectory(runDir);
121
+ } finally {
122
+ if (handle) await handle.close().catch(() => {});
123
+ await rm(temporary, { force: true }).catch(() => {});
124
+ }
125
+ }
126
+
127
+ function validateMeta(meta) {
128
+ return exact(meta, [
129
+ 'schema', 'run_id', 'plan_key', 'selection', 'equipment', 'created_at', 'meta_digest',
130
+ ])
131
+ && meta.schema === PULL_RUN_META_SCHEMA
132
+ && identifier(meta.run_id) && identifier(meta.plan_key)
133
+ && meta.selection === 'pull' && meta.equipment === 'detached-worktree'
134
+ && typeof meta.created_at === 'string'
135
+ && sha256(meta.meta_digest) && selfDigest(meta, 'meta_digest') === meta.meta_digest;
136
+ }
137
+
138
+ export async function inspectRunMode(runDir) {
139
+ const meta = await readJson(path.join(runDir, 'run-meta.json'), 'run meta');
140
+ if (meta?.schema === PULL_RUN_META_SCHEMA) {
141
+ if (!validateMeta(meta)) fail('INVALID_RUN_STORE', 'pull run meta bindingが不正');
142
+ for (const legacyName of ['request.json', 'plan-compile-result.json', 'events.json']) {
143
+ try {
144
+ await lstat(path.join(runDir, legacyName));
145
+ fail('MIXED_RUN_STORE', `pull storeへlegacy artifactが混在: ${legacyName}`);
146
+ } catch (error) {
147
+ if (error instanceof PullRunError) throw error;
148
+ if (error?.code !== 'ENOENT') fail('INVALID_RUN_STORE', `${legacyName}の存在確認に失敗`);
149
+ }
150
+ }
151
+ return { mode: 'pull', meta };
152
+ }
153
+ if (LEGACY_META_SCHEMAS.has(meta?.schema)) {
154
+ try {
155
+ await lstat(path.join(runDir, PULL_EVENTS_FILE));
156
+ fail('MIXED_RUN_STORE', `legacy storeへpull artifactが混在: ${PULL_EVENTS_FILE}`);
157
+ } catch (error) {
158
+ if (error instanceof PullRunError) throw error;
159
+ if (error?.code !== 'ENOENT') fail('INVALID_RUN_STORE', `${PULL_EVENTS_FILE}の存在確認に失敗`);
160
+ }
161
+ return { mode: 'legacy', meta };
162
+ }
163
+ fail('UNSUPPORTED_RUN_STORE_SCHEMA', '未知のrun meta schema', { schema: meta?.schema ?? null });
164
+ }
165
+
166
+ function buildEvent({ events, meta, kind, taskId = null, payload }) {
167
+ const event = {
168
+ schema: PULL_EVENT_SCHEMA,
169
+ run_id: meta.run_id,
170
+ sequence: events.length,
171
+ previous_event_digest: events.at(-1)?.event_digest ?? null,
172
+ kind,
173
+ task_id: taskId,
174
+ recorded_at: now(),
175
+ payload: structuredClone(payload),
176
+ event_digest: '',
177
+ };
178
+ event.event_digest = selfDigest(event, 'event_digest');
179
+ return event;
180
+ }
181
+
182
+ function verifyEvents(events, meta) {
183
+ if (!Array.isArray(events) || events.length < 1) fail('INVALID_PULL_EVENT_CHAIN', 'pull event列が空');
184
+ let previous = null;
185
+ for (const [index, event] of events.entries()) {
186
+ if (!exact(event, [
187
+ 'schema', 'run_id', 'sequence', 'previous_event_digest', 'kind', 'task_id',
188
+ 'recorded_at', 'payload', 'event_digest',
189
+ ])
190
+ || event.schema !== PULL_EVENT_SCHEMA || event.run_id !== meta.run_id
191
+ || event.sequence !== index || event.previous_event_digest !== previous
192
+ || !sha256(event.event_digest) || selfDigest(event, 'event_digest') !== event.event_digest
193
+ || typeof event.kind !== 'string' || !(event.task_id === null || identifier(event.task_id))
194
+ || !plain(event.payload)) {
195
+ fail('INVALID_PULL_EVENT_CHAIN', `pull event chainが不正: sequence ${index}`);
196
+ }
197
+ previous = event.event_digest;
198
+ }
199
+ }
200
+
201
+ async function readPullStore(runDir) {
202
+ const { mode, meta } = await inspectRunMode(runDir);
203
+ if (mode !== 'pull') fail('RUN_MODE_MISMATCH', 'pull commandへlegacy runを渡せない');
204
+ const events = await readJson(path.join(runDir, PULL_EVENTS_FILE), 'pull events');
205
+ verifyEvents(events, meta);
206
+ return { meta, events };
207
+ }
208
+
209
+ function project(events, meta) {
210
+ const intakes = new Map();
211
+ let closed = false;
212
+ for (const event of events) {
213
+ if (event.kind === 'intake_recorded') {
214
+ intakes.set(event.task_id, {
215
+ task_id: event.task_id,
216
+ sequence: event.sequence,
217
+ ...structuredClone(event.payload),
218
+ worker: null,
219
+ accepted: null,
220
+ });
221
+ } else if (event.kind === 'intake_refreshed') {
222
+ const intake = intakes.get(event.task_id);
223
+ if (intake) Object.assign(intake, {
224
+ manifest: structuredClone(event.payload.manifest),
225
+ independence_result_digest: event.payload.independence_result_digest,
226
+ witness_set_digest: event.payload.witness_set_digest,
227
+ intervention: structuredClone(event.payload.intervention),
228
+ });
229
+ } else if (event.kind === 'intervention_changed') {
230
+ const intake = intakes.get(event.task_id);
231
+ if (intake) intake.intervention = structuredClone(event.payload.intervention);
232
+ } else if (event.kind === 'worker_attached') {
233
+ const intake = intakes.get(event.task_id);
234
+ if (intake) intake.worker = { ...structuredClone(event.payload), stopped: false };
235
+ } else if (event.kind === 'worker_stopped' || event.kind === 'worker_resumed') {
236
+ const intake = intakes.get(event.task_id);
237
+ if (intake?.worker) intake.worker.stopped = event.kind === 'worker_stopped';
238
+ } else if (event.kind === 'task_accepted') {
239
+ const intake = intakes.get(event.task_id);
240
+ if (intake) intake.accepted = structuredClone(event.payload);
241
+ } else if (event.kind === 'intake_released') {
242
+ const intake = intakes.get(event.task_id);
243
+ if (intake?.accepted === null) intakes.delete(event.task_id);
244
+ } else if (event.kind === 'run_closed') closed = true;
245
+ }
246
+ return {
247
+ run_id: meta.run_id,
248
+ plan_key: meta.plan_key,
249
+ plan_version: [...intakes.values()][0]?.plan_version ?? null,
250
+ intakes: [...intakes.values()].sort((left, right) => left.sequence - right.sequence),
251
+ closed,
252
+ };
253
+ }
254
+
255
+ function publicIntake(intake) {
256
+ return {
257
+ task_id: intake.task_id,
258
+ actor: structuredClone(intake.actor),
259
+ plan_version: intake.plan_version,
260
+ activation_event_digest: intake.activation_event_digest,
261
+ base_sha: intake.base_sha,
262
+ worktree_path: intake.worktree_path,
263
+ packet_digest: intake.packet.packet_digest,
264
+ manifest_digest: intake.manifest.manifest_digest,
265
+ independence_result_digest: intake.independence_result_digest,
266
+ witness_set_digest: intake.witness_set_digest,
267
+ intervention: structuredClone(intake.intervention),
268
+ worker_attached: intake.worker !== null,
269
+ worker_stopped: intake.worker?.stopped ?? false,
270
+ accepted_head_sha: intake.accepted?.head_sha ?? null,
271
+ };
272
+ }
273
+
274
+ function actorFromEnvironment(environment = process.env) {
275
+ const actor = {
276
+ host: environment.LATTICE_TODO_ACTOR_HOST,
277
+ session: environment.LATTICE_TODO_ACTOR_SESSION,
278
+ agent: environment.LATTICE_TODO_ACTOR_AGENT,
279
+ };
280
+ if (!Object.values(actor).every(identifier)) {
281
+ fail('TODO_ACTOR_REQUIRED', 'intakeにはTodo startと同じactor host/session/agentが必要');
282
+ }
283
+ return actor;
284
+ }
285
+
286
+ function sameActor(left, right) {
287
+ return left?.host === right.host && left?.session === right.session && left?.agent === right.agent;
288
+ }
289
+
290
+ function activeMember(store, planKey) {
291
+ const member = store.members?.find((entry) => entry.plan?.plan_key === planKey);
292
+ if (!member) fail('PLAN_NOT_ACTIVE', `active planが見つからない: ${planKey}`);
293
+ return member;
294
+ }
295
+
296
+ function literalEvent(member, { kind, taskId, actor }) {
297
+ return member.journal?.events?.findLast((event) => (
298
+ event.kind === kind && event.task_id === taskId
299
+ && event.plan_version === member.plan.plan_version && sameActor(event.actor, actor)
300
+ )) ?? null;
301
+ }
302
+
303
+ function resolveStartBinding(store, meta, taskId, actor) {
304
+ const member = activeMember(store, meta.plan_key);
305
+ const task = member.tasks.find((entry) => entry.task_id === taskId);
306
+ if (!task || task.status === 'pending') fail('TASK_NOT_STARTED', `ToDoがstartされていない: ${taskId}`);
307
+ if (task.status !== 'in-progress') {
308
+ fail('TASK_START_BINDING_UNSUPPORTED', `in-progressのliteral startだけをintakeできる: ${taskId}`);
309
+ }
310
+ const activation = literalEvent(member, { kind: 'start', taskId, actor });
311
+ if (activation === null) {
312
+ fail('TASK_START_BINDING_UNSUPPORTED', 'reopen/carry/importをliteral startへ推定しない');
313
+ }
314
+ return { member, task, activation };
315
+ }
316
+
317
+ async function headSha(repoRoot) {
318
+ const result = await run('git', ['rev-parse', 'HEAD'], repoRoot);
319
+ const value = result.stdout.trim();
320
+ if (!SHA1.test(value)) fail('REPO_UNRESOLVED', 'canonical HEADを解決できない');
321
+ return value;
322
+ }
323
+
324
+ function boundaryFor(artifact, taskId) {
325
+ return artifact?.task_boundaries?.find((entry) => entry.task_id === taskId) ?? null;
326
+ }
327
+
328
+ function pathTouches(boundary, changed) {
329
+ return boundary === changed
330
+ || (boundary.endsWith('/') && changed.startsWith(boundary))
331
+ || (changed.endsWith('/') && boundary.startsWith(changed));
332
+ }
333
+
334
+ async function boundaryVerdict({ repoRoot, store, member, meta, taskId, intakeBase }) {
335
+ let artifact;
336
+ let witnessSet;
337
+ try {
338
+ [artifact, witnessSet] = await Promise.all([
339
+ readTodoIndependenceArtifact({ repoRoot, store, planKey: meta.plan_key }),
340
+ readTodoWitnessSet({ repoRoot, planKey: meta.plan_key }),
341
+ ]);
342
+ } catch (error) {
343
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
344
+ detail: { cause: error?.code ?? 'artifact_read_failed' }, artifact: null, witnessSet: null };
345
+ }
346
+ const bindingValid = artifact !== null
347
+ && artifact.schema === TODO_INDEPENDENCE_SCHEMA
348
+ && artifact.project_id === store.project_id
349
+ && artifact.plan_key === meta.plan_key
350
+ && artifact.plan_version === member.plan.plan_version
351
+ && artifact.topology_digest === member.plan.topology_digest
352
+ && witnessSet?.project_id === store.project_id
353
+ && witnessSet?.plan_key === meta.plan_key
354
+ && witnessSet?.witness_set_digest === artifact.witness_set_digest;
355
+ if (!bindingValid) {
356
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
357
+ detail: { cause: 'artifact_binding_mismatch' }, artifact, witnessSet };
358
+ }
359
+ if (artifact.outcome !== 'compiled') {
360
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
361
+ detail: { cause: 'independence_outcome_unknown' }, artifact, witnessSet };
362
+ }
363
+ const boundary = boundaryFor(artifact, taskId);
364
+ if (boundary === null || !Array.isArray(boundary.paths)) {
365
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'declare_and_recompile',
366
+ detail: { cause: 'task_boundary_missing' }, artifact, witnessSet };
367
+ }
368
+ if (!SHA1.test(artifact.base_sha)) {
369
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'recompile_independence',
370
+ detail: { cause: 'compiled_base_missing' }, artifact, witnessSet };
371
+ }
372
+ const artifactBeforeIntake = await run(
373
+ 'git', ['merge-base', '--is-ancestor', artifact.base_sha, intakeBase], repoRoot, [0, 1, 128],
374
+ );
375
+ let comparisonBase = artifact.base_sha;
376
+ let comparisonHead = intakeBase;
377
+ let baseRelation = 'compiled_at_or_before_intake';
378
+ if (artifactBeforeIntake.code !== 0) {
379
+ const intakeBeforeArtifact = await run(
380
+ 'git', ['merge-base', '--is-ancestor', intakeBase, artifact.base_sha], repoRoot, [0, 1, 128],
381
+ );
382
+ if (intakeBeforeArtifact.code !== 0) {
383
+ return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
384
+ detail: { cause: 'compiled_base_unreachable' }, artifact, witnessSet };
385
+ }
386
+ comparisonBase = intakeBase;
387
+ comparisonHead = artifact.base_sha;
388
+ baseRelation = 'compiled_after_intake';
389
+ }
390
+ if (![comparisonBase, comparisonHead].every(SHA1.test.bind(SHA1))) {
391
+ return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
392
+ detail: { cause: 'compiled_base_unreachable' }, artifact, witnessSet };
393
+ }
394
+ let changedPaths = [];
395
+ if (comparisonBase !== comparisonHead) {
396
+ const changed = await run(
397
+ 'git', ['diff', '--name-only', '-z', `${comparisonBase}..${comparisonHead}`], repoRoot,
398
+ );
399
+ changedPaths = changed.stdout.split('\0').filter(Boolean).sort();
400
+ }
401
+ const intersecting = changedPaths.filter((changed) => (
402
+ boundary.paths.some((declared) => pathTouches(declared, changed))
403
+ ));
404
+ if (intersecting.length > 0) {
405
+ return { state: 'hold', reason: 'version_drift', next_action: 'recompile_independence',
406
+ detail: { cause: 'boundary_intersecting_drift', changed_paths: intersecting }, artifact, witnessSet };
407
+ }
408
+ const taskUnknowns = artifact.unknowns.filter((unknown) => unknown.task_id === taskId);
409
+ if (taskUnknowns.length > 0) {
410
+ return { state: 'hold', reason: 'boundary_unverified', next_action: 'resolve_unknown_and_recompile',
411
+ detail: { cause: 'task_boundary_unknown', unknowns: taskUnknowns }, artifact, witnessSet };
412
+ }
413
+ return { state: 'none', reason: null, next_action: null,
414
+ detail: { compiled_base_sha: artifact.base_sha, changed_path_count: changedPaths.length,
415
+ base_relation: baseRelation },
416
+ artifact, witnessSet };
417
+ }
418
+
419
+ function buildManifest(witnessSet, taskId) {
420
+ const witness = witnessSet?.manual_witness?.[taskId];
421
+ if (!plain(witness)) fail('BOUNDARY_UNVERIFIED', `witnessが無い: ${taskId}`);
422
+ const manifest = {
423
+ schema: BOUNDARY_MANIFEST_SCHEMA,
424
+ todo_id: taskId,
425
+ owns: structuredClone(witness.owns),
426
+ reads: structuredClone(witness.reads),
427
+ writes: structuredClone(witness.writes),
428
+ resources: structuredClone(witness.resources),
429
+ state_effects: structuredClone(witness.state_effects),
430
+ unknowns: structuredClone(witness.unknowns),
431
+ affected_tests: structuredClone(witness.affected_tests),
432
+ ...(witness.lines === undefined ? {} : { lines: structuredClone(witness.lines) }),
433
+ graph_evidence: [],
434
+ witness_provenance: Object.fromEntries(
435
+ witness.resources.map((resourceId) => [resourceId, 'manual_state_effect']),
436
+ ),
437
+ manifest_digest: '',
438
+ };
439
+ manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
440
+ if (!validateRuntimeBoundaryManifest(manifest)) {
441
+ fail('BOUNDARY_UNVERIFIED', `runtime boundary manifestへ変換できない: ${taskId}`);
442
+ }
443
+ return manifest;
444
+ }
445
+
446
+ function planningConstraint(artifact, taskId, state, member) {
447
+ const active = new Set(state.intakes.filter((entry) => entry.accepted === null).map((entry) => entry.task_id));
448
+ const conflict = artifact?.conflicts?.find((entry) => (
449
+ entry.task_ids.includes(taskId)
450
+ && entry.task_ids.some((other) => other !== taskId && active.has(other))
451
+ ));
452
+ if (conflict) return { kind: 'conflict', detail: structuredClone(conflict) };
453
+ const precedence = artifact?.precedences?.find((entry) => (
454
+ entry.to_task_id === taskId && (() => {
455
+ const predecessorIntake = state.intakes.find((intake) => (
456
+ intake.task_id === entry.from_task_id
457
+ ));
458
+ const predecessorDone = member.tasks.find((task) => (
459
+ task.task_id === entry.from_task_id
460
+ ))?.status === 'done';
461
+ if (predecessorIntake !== undefined) return predecessorIntake.accepted === null;
462
+ return !predecessorDone;
463
+ })()
464
+ ));
465
+ if (precedence) return { kind: 'precedence', detail: structuredClone(precedence) };
466
+ return null;
467
+ }
468
+
469
+ function interventionPayload(verdict, constraint = null) {
470
+ if (verdict.state === 'none' && constraint === null) {
471
+ return { state: 'none', reason: null, next_action: null, lease_state: 'granted', detail: verdict.detail };
472
+ }
473
+ return {
474
+ state: 'hold',
475
+ reason: constraint === null ? verdict.reason
476
+ : constraint.kind === 'precedence' ? 'planning_precedence' : 'planning_conflict',
477
+ next_action: constraint === null ? verdict.next_action
478
+ : constraint.kind === 'precedence' ? 'wait_for_predecessor_accept'
479
+ : 'wait_for_prior_intake_or_resolve_seam',
480
+ lease_state: 'withheld',
481
+ detail: constraint === null ? verdict.detail : { [constraint.kind]: constraint.detail },
482
+ };
483
+ }
484
+
485
+ function packetFor({ meta, taskId, baseSha, manifest }) {
486
+ const packet = {
487
+ schema: 'lattice.pull_executor_packet.v1',
488
+ run_id: meta.run_id,
489
+ todo_id: taskId,
490
+ base_sha: baseSha,
491
+ scope: { writes: [...manifest.writes] },
492
+ manifest_digest: manifest.manifest_digest,
493
+ packet_digest: '',
494
+ };
495
+ packet.packet_digest = selfDigest(packet, 'packet_digest');
496
+ return packet;
497
+ }
498
+
499
+ async function acquirePullLock(runDir, operation, requestId) {
500
+ return acquireRuntimeLifecycleLock({
501
+ lockPath: path.join(runDir, '.pull-intake.lock'),
502
+ sessionNonceDigest: digestArtifact({ run_dir: runDir, schema: PULL_RUN_META_SCHEMA }),
503
+ operation,
504
+ requestId,
505
+ timeoutMs: 0,
506
+ });
507
+ }
508
+
509
+ async function acquireStartBindingLock(repoRoot, operation) {
510
+ return acquireRuntimeLifecycleLock({
511
+ lockPath: path.join(repoRoot, '.lattice', 'todo', '.start-binding.lock'),
512
+ sessionNonceDigest: digestArtifact({ repo_root: path.resolve(repoRoot), schema: PULL_RUN_META_SCHEMA }),
513
+ operation,
514
+ requestId: randomUUID(),
515
+ timeoutMs: 0,
516
+ });
517
+ }
518
+
519
+ async function activePullIntakesForStart(repoRoot, { planKey, taskId, activationEventDigest }) {
520
+ const root = path.join(repoRoot, '.lattice', 'runs');
521
+ let entries;
522
+ try { entries = await readdir(root, { withFileTypes: true }); }
523
+ catch (error) {
524
+ if (error?.code === 'ENOENT') return [];
525
+ fail('INVALID_RUN_STORE', 'run store一覧を読めない');
526
+ }
527
+ const active = [];
528
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
529
+ if (!entry.isDirectory() || !identifier(entry.name)) {
530
+ fail('INVALID_RUN_STORE', `不正なrun store entry: ${entry.name}`);
531
+ }
532
+ const runDir = path.join(root, entry.name);
533
+ const mode = await inspectRunMode(runDir);
534
+ if (mode.mode !== 'pull') continue;
535
+ const stored = await readPullStore(runDir);
536
+ const state = project(stored.events, stored.meta);
537
+ if (state.closed || state.plan_key !== planKey) continue;
538
+ const intake = state.intakes.find((candidate) => candidate.task_id === taskId
539
+ && candidate.activation_event_digest === activationEventDigest
540
+ && candidate.accepted === null);
541
+ if (intake !== undefined) active.push({
542
+ run_id: state.run_id,
543
+ run_ref: `.lattice/runs/${entry.name}`,
544
+ activation_event_digest: intake.activation_event_digest,
545
+ });
546
+ }
547
+ return active;
548
+ }
549
+
550
+ export async function withStartRetractionGuard({
551
+ repoRoot, planKey, taskId, activationEventDigest, action,
552
+ }) {
553
+ if (!identifier(planKey) || !identifier(taskId) || !sha256(activationEventDigest)
554
+ || typeof action !== 'function') {
555
+ fail('INVALID_START_RETRACTION', 'start retraction guard入力が不正');
556
+ }
557
+ const lock = await acquireStartBindingLock(repoRoot, 'start-retract');
558
+ try {
559
+ const active = await activePullIntakesForStart(repoRoot, {
560
+ planKey, taskId, activationEventDigest,
561
+ });
562
+ if (active.length > 0) {
563
+ fail('ACTIVE_PULL_INTAKE', 'active intakeをreleaseしてからstartを撤回すること', {
564
+ plan_key: planKey, task_id: taskId, active_intakes: active,
565
+ next_action: 'lattice run intake release --run <run_ref> --task <task_id>',
566
+ });
567
+ }
568
+ return await action();
569
+ } finally { await lock.release(); }
570
+ }
571
+
572
+ async function appendEvent(runDir, current, event) {
573
+ const next = [...current.events, event];
574
+ verifyEvents(next, current.meta);
575
+ await replaceCanonical(runDir, PULL_EVENTS_FILE, next);
576
+ return { meta: current.meta, events: next };
577
+ }
578
+
579
+ export async function startPullRun({ repoRoot, runDir, runId, planKey, equipment }) {
580
+ if (![repoRoot, runDir].every((value) => typeof value === 'string' && path.isAbsolute(value))
581
+ || !identifier(runId) || !identifier(planKey) || equipment !== 'detached-worktree') {
582
+ fail('INVALID_PULL_START', 'pull start入力が不正');
583
+ }
584
+ const temporary = await mkdtemp(path.join(path.dirname(runDir), `.${runId}.pull-tmp-`));
585
+ const meta = {
586
+ schema: PULL_RUN_META_SCHEMA,
587
+ run_id: runId,
588
+ plan_key: planKey,
589
+ selection: 'pull',
590
+ equipment,
591
+ created_at: now(),
592
+ meta_digest: '',
593
+ };
594
+ meta.meta_digest = selfDigest(meta, 'meta_digest');
595
+ const events = [buildEvent({
596
+ events: [], meta, kind: 'run_started',
597
+ payload: { selection: 'pull', equipment, plan_key: planKey },
598
+ })];
599
+ try {
600
+ try {
601
+ await lstat(runDir);
602
+ fail('RUN_EXISTS', 'run storeが既に存在する');
603
+ } catch (error) {
604
+ if (error instanceof PullRunError) throw error;
605
+ if (error?.code !== 'ENOENT') throw error;
606
+ }
607
+ await writeCanonicalNew(path.join(temporary, 'run-meta.json'), meta);
608
+ await writeCanonicalNew(path.join(temporary, PULL_EVENTS_FILE), events);
609
+ await rename(temporary, runDir);
610
+ } catch (error) {
611
+ await rm(temporary, { recursive: true, force: true });
612
+ throw error;
613
+ }
614
+ return {
615
+ schema: 'lattice.pull_run_start_result.v1',
616
+ outcome: 'started', run_id: runId, plan_key: planKey,
617
+ selection: 'pull', equipment, intake_count: 0,
618
+ };
619
+ }
620
+
621
+ export async function intakePullTask({ repoRoot, runDir, taskId, environment = process.env }) {
622
+ if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
623
+ const actor = actorFromEnvironment(environment);
624
+ const lock = await acquirePullLock(runDir, 'intake', `${taskId}-${randomUUID()}`);
625
+ try {
626
+ let current = await readPullStore(runDir);
627
+ let state = project(current.events, current.meta);
628
+ if (state.closed) fail('RUN_CLOSED', 'closed pull runへintakeできない');
629
+ const existing = state.intakes.find((entry) => entry.task_id === taskId);
630
+ if (existing !== undefined) {
631
+ if (!sameActor(existing.actor, actor)) {
632
+ fail('INTAKE_BINDING_CONFLICT', '同taskを別actor bindingでintakeできない');
633
+ }
634
+ const store = await readTodoStore({ repoRoot });
635
+ const member = activeMember(store, current.meta.plan_key);
636
+ const activation = literalEvent(member, { kind: 'start', taskId, actor });
637
+ if (member.plan.plan_version !== existing.plan_version
638
+ || member.plan.topology_digest !== existing.topology_digest
639
+ || activation?.event_digest !== existing.activation_event_digest) {
640
+ fail('INTAKE_BINDING_CONFLICT', '既存intakeのactivation/version bindingと一致しない');
641
+ }
642
+ if (existing.accepted !== null) {
643
+ const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
644
+ already_intaked: true, refreshed: false, ...publicIntake(existing) };
645
+ result.result_digest = digestArtifact(result);
646
+ return result;
647
+ }
648
+
649
+ const verdict = await boundaryVerdict({
650
+ repoRoot, store, member, meta: current.meta, taskId, intakeBase: existing.base_sha,
651
+ });
652
+ let manifest;
653
+ try { manifest = buildManifest(verdict.witnessSet, taskId); }
654
+ catch (error) {
655
+ if (!(error instanceof PullRunError)) throw error;
656
+ manifest = {
657
+ schema: BOUNDARY_MANIFEST_SCHEMA, todo_id: taskId,
658
+ owns: [], reads: [], writes: [], resources: [], state_effects: [], unknowns: [],
659
+ affected_tests: [], graph_evidence: [], witness_provenance: {}, manifest_digest: '',
660
+ };
661
+ manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
662
+ verdict.state = 'hold'; verdict.reason = 'boundary_unverified';
663
+ verdict.next_action = 'declare_and_recompile'; verdict.detail = { cause: error.code };
664
+ }
665
+ const constraint = verdict.state === 'none'
666
+ ? planningConstraint(verdict.artifact, taskId, state, member) : null;
667
+ const intervention = interventionPayload(verdict, constraint);
668
+
669
+ const confirmation = await readTodoStore({ repoRoot });
670
+ const confirmedMember = activeMember(confirmation, current.meta.plan_key);
671
+ const confirmedActivation = literalEvent(confirmedMember, { kind: 'start', taskId, actor });
672
+ if (confirmedMember.plan.plan_version !== member.plan.plan_version
673
+ || confirmedMember.plan.topology_digest !== member.plan.topology_digest
674
+ || confirmedActivation?.event_digest !== activation.event_digest) {
675
+ intervention.state = 'hold'; intervention.reason = 'version_drift';
676
+ intervention.next_action = 'retry_intake_on_active_version'; intervention.lease_state = 'withheld';
677
+ intervention.detail = { cause: 'todo_binding_changed_during_intake', equipment_state: 'isolated' };
678
+ }
679
+ // packet/worktreeは初回intakeのequipment identityとして不変にする。
680
+ // 再compile後の実効境界はmanifestと介入へ投影し、attach済みhold workerも同じ設備で復旧する。
681
+ const refresh = {
682
+ manifest,
683
+ independence_result_digest: verdict.artifact?.result_digest ?? null,
684
+ witness_set_digest: verdict.witnessSet?.witness_set_digest ?? null,
685
+ intervention,
686
+ };
687
+ const previous = {
688
+ manifest: existing.manifest,
689
+ independence_result_digest: existing.independence_result_digest,
690
+ witness_set_digest: existing.witness_set_digest,
691
+ intervention: existing.intervention,
692
+ };
693
+ const refreshed = digestArtifact(refresh) !== digestArtifact(previous);
694
+ if (refreshed) {
695
+ current = await appendEvent(runDir, current, buildEvent({
696
+ events: current.events, meta: current.meta, kind: 'intake_refreshed', taskId,
697
+ payload: refresh,
698
+ }));
699
+ }
700
+ const updated = project(current.events, current.meta).intakes
701
+ .find((entry) => entry.task_id === taskId);
702
+ if (updated.worker && intervention.state === 'hold' && !updated.worker.stopped) {
703
+ await signalAttachedWorker(updated, 'SIGSTOP');
704
+ current = await appendEvent(runDir, current, buildEvent({
705
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
706
+ payload: { reason: intervention.reason },
707
+ }));
708
+ } else if (updated.worker?.stopped && intervention.state === 'none') {
709
+ await signalAttachedWorker(updated, 'SIGCONT');
710
+ current = await appendEvent(runDir, current, buildEvent({
711
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
712
+ payload: { released_by: 'intake_refresh' },
713
+ }));
714
+ }
715
+ state = project(current.events, current.meta);
716
+ const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
717
+ already_intaked: true, refreshed,
718
+ ...publicIntake(state.intakes.find((entry) => entry.task_id === taskId)) };
719
+ result.result_digest = digestArtifact(result);
720
+ return result;
721
+ }
722
+
723
+ const bindingLock = await acquireStartBindingLock(repoRoot, 'intake-bind');
724
+ try {
725
+ const store = await readTodoStore({ repoRoot });
726
+ const { member, activation } = resolveStartBinding(store, current.meta, taskId, actor);
727
+ const baseSha = await headSha(repoRoot);
728
+ const verdict = await boundaryVerdict({
729
+ repoRoot, store, member, meta: current.meta, taskId, intakeBase: baseSha,
730
+ });
731
+ let manifest;
732
+ try { manifest = buildManifest(verdict.witnessSet, taskId); }
733
+ catch (error) {
734
+ if (!(error instanceof PullRunError)) throw error;
735
+ manifest = {
736
+ schema: BOUNDARY_MANIFEST_SCHEMA, todo_id: taskId,
737
+ owns: [], reads: [], writes: [], resources: [], state_effects: [], unknowns: [],
738
+ affected_tests: [], graph_evidence: [], witness_provenance: {}, manifest_digest: '',
739
+ };
740
+ manifest.manifest_digest = selfDigest(manifest, 'manifest_digest');
741
+ verdict.state = 'hold'; verdict.reason = 'boundary_unverified';
742
+ verdict.next_action = 'declare_and_recompile'; verdict.detail = { cause: error.code };
743
+ }
744
+ const packet = packetFor({ meta: current.meta, taskId, baseSha, manifest });
745
+ const worktreePath = await ensureScriptedWorktree({ repoRoot, runDir, packet });
746
+ const firstVersion = state.plan_version;
747
+ if (firstVersion !== null && firstVersion !== member.plan.plan_version) {
748
+ verdict.state = 'hold'; verdict.reason = 'version_drift'; verdict.next_action = 'close_or_rebase_run';
749
+ verdict.detail = { cause: 'run_plan_version_mismatch', run_version: firstVersion,
750
+ task_version: member.plan.plan_version };
751
+ }
752
+ const constraint = verdict.state === 'none'
753
+ ? planningConstraint(verdict.artifact, taskId, state, member) : null;
754
+ const intervention = interventionPayload(verdict, constraint);
755
+
756
+ // retract側と同じrepo-wide lock内でstart bindingを再確認してから記録する。
757
+ // 先にretractされたら確認がtyped failし、先にintakeを記録したらretract側の走査が拒否する。
758
+ const confirmation = await readTodoStore({ repoRoot });
759
+ const confirmed = resolveStartBinding(confirmation, current.meta, taskId, actor);
760
+ if (confirmed.member.plan.plan_version !== member.plan.plan_version
761
+ || confirmed.member.plan.topology_digest !== member.plan.topology_digest
762
+ || confirmed.activation.event_digest !== activation.event_digest) {
763
+ intervention.state = 'hold'; intervention.reason = 'version_drift';
764
+ intervention.next_action = 'retry_intake_on_active_version'; intervention.lease_state = 'withheld';
765
+ intervention.detail = { cause: 'todo_binding_changed_during_intake', equipment_state: 'isolated' };
766
+ }
767
+ const payload = {
768
+ actor, project_id: store.project_id,
769
+ plan_version: member.plan.plan_version,
770
+ topology_digest: member.plan.topology_digest,
771
+ activation_event_digest: activation.event_digest,
772
+ base_sha: baseSha,
773
+ worktree_path: worktreePath,
774
+ packet,
775
+ manifest,
776
+ independence_result_digest: verdict.artifact?.result_digest ?? null,
777
+ witness_set_digest: verdict.witnessSet?.witness_set_digest ?? null,
778
+ intervention,
779
+ };
780
+ current = await appendEvent(runDir, current, buildEvent({
781
+ events: current.events, meta: current.meta, kind: 'intake_recorded', taskId, payload,
782
+ }));
783
+ const intake = project(current.events, current.meta).intakes.find((entry) => entry.task_id === taskId);
784
+ const result = { schema: 'lattice.pull_intake_result.v1', outcome: 'intaked',
785
+ already_intaked: false, refreshed: false, ...publicIntake(intake) };
786
+ result.result_digest = digestArtifact(result);
787
+ return result;
788
+ } finally { await bindingLock.release(); }
789
+ } finally { await lock.release(); }
790
+ }
791
+
792
+ export async function releasePullTask({ runDir, taskId, environment = process.env }) {
793
+ if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
794
+ const actor = actorFromEnvironment(environment);
795
+ const lock = await acquirePullLock(runDir, 'release', `release-${randomUUID()}`);
796
+ try {
797
+ let current = await readPullStore(runDir);
798
+ const state = project(current.events, current.meta);
799
+ if (state.closed) fail('RUN_CLOSED', 'closed pull runのintakeをreleaseできない');
800
+ const intake = state.intakes.find((entry) => entry.task_id === taskId);
801
+ if (intake === undefined) fail('INTAKE_NOT_FOUND', 'active intakeが存在しない', { task_id: taskId });
802
+ if (intake.accepted !== null) {
803
+ fail('INTAKE_ALREADY_ACCEPTED', 'accepted intakeはreleaseできない', { task_id: taskId });
804
+ }
805
+ if (intake.worker !== null) {
806
+ fail('INTAKE_WORKER_ATTACHED', 'workerがattach済みのintakeはreleaseできない', {
807
+ task_id: taskId,
808
+ next_action: 'workerを安全に停止・detachできる正規経路を先に使う',
809
+ });
810
+ }
811
+ if (!sameActor(intake.actor, actor)) {
812
+ fail('INTAKE_BINDING_CONFLICT', 'intakeを作成したactorだけがreleaseできる', {
813
+ task_id: taskId,
814
+ });
815
+ }
816
+ current = await appendEvent(runDir, current, buildEvent({
817
+ events: current.events, meta: current.meta, kind: 'intake_released', taskId,
818
+ payload: { released_by: actor },
819
+ }));
820
+ const output = {
821
+ schema: 'lattice.pull_intake_release_result.v1', outcome: 'released',
822
+ run_id: state.run_id, plan_key: state.plan_key, task_id: taskId,
823
+ release_event_digest: current.events.at(-1).event_digest, result_digest: '',
824
+ };
825
+ output.result_digest = digestArtifact(output);
826
+ return output;
827
+ } finally { await lock.release(); }
828
+ }
829
+
830
+ async function observeProcessBinding(pid) {
831
+ if (!Number.isSafeInteger(pid) || pid < 1) fail('INVALID_WORKER_PID', 'pidが正整数でない');
832
+ const processStartIdentity = await observeManagedProcessStartIdentity(pid);
833
+ const { stdout: pgidOut } = await execFileAsync('/bin/ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
834
+ const { stdout: argvOut } = await execFileAsync('/bin/ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
835
+ const processGroupId = Number(pgidOut.trim());
836
+ const argv = argvOut.trim();
837
+ const expected = { pid, process_group_id: processGroupId, process_start_identity: processStartIdentity };
838
+ if (!validateExpectedWorkerProcess(expected) || argv.length === 0) {
839
+ fail('WORKER_IDENTITY_MISMATCH', 'worker process identityを完全観測できない');
840
+ }
841
+ return {
842
+ pid, process_group_id: processGroupId,
843
+ process_start_identity: processStartIdentity,
844
+ argv_digest: createHash('sha256').update(argv, 'utf8').digest('hex'),
845
+ };
846
+ }
847
+
848
+ async function signalAttachedWorker(intake, signal) {
849
+ if (intake.worker === null) return false;
850
+ let observed;
851
+ try { observed = await observeProcessBinding(intake.worker.pid); }
852
+ catch { fail('WORKER_IDENTITY_MISMATCH', 'signal前にworkerを再認証できない'); }
853
+ if (observed.process_start_identity.identity_digest
854
+ !== intake.worker.process_start_identity.identity_digest
855
+ || observed.argv_digest !== intake.worker.argv_digest
856
+ || observed.process_group_id !== intake.worker.process_group_id) {
857
+ fail('WORKER_IDENTITY_MISMATCH', 'signal前のlstart/argv/pgidがattach bindingと一致しない');
858
+ }
859
+ try { process.kill(intake.worker.pid, signal); }
860
+ catch { fail('WORKER_SIGNAL_FAILED', `workerへ${signal}を送れない`); }
861
+ return true;
862
+ }
863
+
864
+ function validateAttachInput(input) {
865
+ if (!exact(input, [
866
+ 'schema', 'name', 'session', 'pid', 'started_identity', 'argv_digest', 'recorded_at',
867
+ ])
868
+ || input.schema !== 'lattice.pull_worker_attach_input.v1'
869
+ || !identifier(input.name) || !identifier(input.session)
870
+ || !Number.isSafeInteger(input.pid) || input.pid < 1
871
+ || typeof input.started_identity !== 'string' || input.started_identity.length < 1
872
+ || input.started_identity.length > 512
873
+ || !sha256(input.argv_digest)
874
+ || !timestamp(input.recorded_at)) {
875
+ fail('INVALID_WORKER_ATTACH_INPUT', '生のexpected start identityとargv digestを持つattach inputが不正');
876
+ }
877
+ }
878
+
879
+ export async function attachPullWorker({ runDir, taskId, input, environment = process.env }) {
880
+ validateAttachInput(input);
881
+ const actor = actorFromEnvironment(environment);
882
+ const lock = await acquirePullLock(runDir, 'attach', `${taskId}-${randomUUID()}`);
883
+ try {
884
+ let current = await readPullStore(runDir);
885
+ let state = project(current.events, current.meta);
886
+ const intake = state.intakes.find((entry) => entry.task_id === taskId);
887
+ if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
888
+ if (!sameActor(intake.actor, actor)) fail('WORKER_ACTOR_MISMATCH', 'start/intake actorだけがattachできる');
889
+ if (input.name !== actor.agent || input.session !== actor.session) {
890
+ fail('WORKER_ACTOR_MISMATCH', 'attach inputの席identityがTodo actorと一致しない');
891
+ }
892
+ const binding = await observeProcessBinding(input.pid);
893
+ if (binding.process_group_id !== input.pid
894
+ || binding.process_start_identity.started_identity !== input.started_identity
895
+ || binding.argv_digest !== input.argv_digest) {
896
+ fail('WORKER_IDENTITY_MISMATCH', 'expected pid/lstart/argvと現在のOS観測が一致しない');
897
+ }
898
+ const occupied = state.intakes.find((entry) => (
899
+ entry.task_id !== taskId && entry.accepted === null && entry.worker?.pid === input.pid
900
+ ));
901
+ if (occupied) {
902
+ fail('WORKER_ALREADY_ATTACHED', `同じworkerは複数active intakeへattachできない: ${occupied.task_id}`);
903
+ }
904
+ if (intake.worker !== null) {
905
+ if (digestArtifact(binding) !== digestArtifact({
906
+ pid: intake.worker.pid, process_group_id: intake.worker.process_group_id,
907
+ process_start_identity: intake.worker.process_start_identity,
908
+ argv_digest: intake.worker.argv_digest,
909
+ })) fail('WORKER_BINDING_CONFLICT', '同taskへ別worker identityをattachできない');
910
+ } else {
911
+ current = await appendEvent(runDir, current, buildEvent({
912
+ events: current.events, meta: current.meta, kind: 'worker_attached', taskId,
913
+ payload: binding,
914
+ }));
915
+ state = project(current.events, current.meta);
916
+ }
917
+ const refreshed = state.intakes.find((entry) => entry.task_id === taskId);
918
+ if (refreshed.intervention.state === 'hold' && !refreshed.worker.stopped) {
919
+ await signalAttachedWorker(refreshed, 'SIGSTOP');
920
+ current = await appendEvent(runDir, current, buildEvent({
921
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId,
922
+ payload: { reason: refreshed.intervention.reason },
923
+ }));
924
+ }
925
+ const result = { schema: 'lattice.pull_worker_attach_result.v1', outcome: 'attached',
926
+ task_id: taskId, pid: input.pid, stopped: project(current.events, current.meta)
927
+ .intakes.find((entry) => entry.task_id === taskId).worker.stopped };
928
+ result.result_digest = digestArtifact(result);
929
+ return result;
930
+ } finally { await lock.release(); }
931
+ }
932
+
933
+ function observationModel(state) {
934
+ const intakes = state.intakes.filter((entry) => (
935
+ entry.accepted === null && entry.intervention.state !== 'hold'
936
+ ));
937
+ const manifests = Object.fromEntries(intakes.map((entry) => [entry.task_id, entry.manifest]));
938
+ const nodes = intakes.map((entry) => ({ todo_id: entry.task_id })).sort((l, r) => l.todo_id.localeCompare(r.todo_id));
939
+ const plan = {
940
+ schema: 'lattice.runtime_plan.v1',
941
+ plan_ref: `pull-${state.run_id}-observation`, plan_epoch: 1,
942
+ request_digest: digestArtifact({ run_id: state.run_id, task_ids: nodes.map((entry) => entry.todo_id) }),
943
+ base_sha: intakes[0]?.base_sha ?? '0'.repeat(40),
944
+ nodes, precedence: [], conflicts: [], capacity: { executors: Math.max(1, intakes.length) },
945
+ manifest_digests: Object.fromEntries(nodes.map(({ todo_id: taskId }) => (
946
+ [taskId, manifests[taskId].manifest_digest]
947
+ ))),
948
+ claim: { mode: 'exact_minimum' }, predecessor_refs: [], plan_digest: '',
949
+ };
950
+ plan.plan_digest = selfDigest(plan, 'plan_digest');
951
+ if (!validateRuntimePlan(plan)) fail('CONTRACT_VIOLATION', 'pull observation planを構成できない');
952
+ return { intakes, manifests, plan };
953
+ }
954
+
955
+ function doneBinding(store, intake) {
956
+ const member = activeMember(store, intake.plan_key ?? store.members
957
+ .find((entry) => entry.plan.plan_version === intake.plan_version)?.plan.plan_key);
958
+ if (member.plan.plan_version !== intake.plan_version
959
+ || member.plan.topology_digest !== intake.topology_digest) {
960
+ fail('VERSION_DRIFT', 'accept時にplan version/topologyが変わっている');
961
+ }
962
+ const task = member.tasks.find((entry) => entry.task_id === intake.task_id);
963
+ if (task?.status !== 'done') fail('TASK_NOT_DONE', `todo done前はacceptできない: ${intake.task_id}`);
964
+ const event = literalEvent(member, { kind: 'done', taskId: intake.task_id, actor: intake.actor });
965
+ if (event === null) fail('TASK_DONE_BINDING_UNSUPPORTED', 'same-version literal authored doneだけをacceptする');
966
+ return event;
967
+ }
968
+
969
+ async function changeIntervention(runDir, current, taskId, intervention) {
970
+ return appendEvent(runDir, current, buildEvent({
971
+ events: current.events, meta: current.meta, kind: 'intervention_changed', taskId,
972
+ payload: { intervention },
973
+ }));
974
+ }
975
+
976
+ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = process.env }) {
977
+ const actor = actorFromEnvironment(environment);
978
+ const lock = await acquirePullLock(runDir, 'accept', `${taskId}-${randomUUID()}`);
979
+ try {
980
+ let current = await readPullStore(runDir);
981
+ let state = project(current.events, current.meta);
982
+ const intake = state.intakes.find((entry) => entry.task_id === taskId);
983
+ if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
984
+ if (!sameActor(intake.actor, actor)) fail('WORKER_ACTOR_MISMATCH', 'intake actorだけがacceptできる');
985
+ if (intake.accepted !== null) {
986
+ const result = { schema: 'lattice.pull_accept_result.v1', outcome: 'accepted',
987
+ already_accepted: true, task_id: taskId, head_sha: intake.accepted.head_sha,
988
+ done_event_digest: intake.accepted.done_event_digest };
989
+ result.result_digest = digestArtifact(result); return result;
990
+ }
991
+ if (intake.intervention.state === 'hold') {
992
+ fail('TASK_HELD', 'hold中taskはacceptできない', {
993
+ reason: intake.intervention.reason, next_action: intake.intervention.next_action,
994
+ });
995
+ }
996
+ const store = await readTodoStore({ repoRoot });
997
+ const done = doneBinding(store, { ...intake, plan_key: current.meta.plan_key });
998
+ const checkpoint = await captureWorktreeDiff({
999
+ worktreePath: intake.worktree_path, baseSha: intake.base_sha,
1000
+ });
1001
+ const model = observationModel(state);
1002
+ const packets = Object.fromEntries(model.intakes.map((entry) => [entry.task_id, entry.packet]));
1003
+ const direct = detectCheckpointFindings({
1004
+ todoId: taskId, checkpoint, packets, manifests: model.manifests,
1005
+ runningTodoIds: model.intakes.map((entry) => entry.task_id),
1006
+ }).findings;
1007
+ const observations = [];
1008
+ for (const entry of model.intakes) {
1009
+ if (entry.task_id === taskId) {
1010
+ observations.push({ todo_id: taskId, paths: checkpoint.diff.entries.map((item) => item.path) });
1011
+ } else if (entry.last_checkpoint?.diff?.entries) {
1012
+ observations.push({ todo_id: entry.task_id,
1013
+ paths: entry.last_checkpoint.diff.entries.map((item) => item.path) });
1014
+ }
1015
+ }
1016
+ const independent = classifyObservedDiff({
1017
+ plan: model.plan, manifests: model.manifests, observations,
1018
+ relevantTodoIds: model.intakes.map((entry) => entry.task_id),
1019
+ }).findings;
1020
+ const findings = [...direct, ...independent].filter((finding, index, all) => (
1021
+ index === all.findIndex((candidate) => digestArtifact(candidate) === digestArtifact(finding))
1022
+ ));
1023
+ if (findings.length > 0) {
1024
+ const affected = new Set(findings.flatMap((finding) => finding.todo_ids));
1025
+ for (const affectedTaskId of [...affected].sort()) {
1026
+ const affectedIntake = project(current.events, current.meta).intakes
1027
+ .find((entry) => entry.task_id === affectedTaskId);
1028
+ if (!affectedIntake || affectedIntake.accepted !== null) continue;
1029
+ const intervention = { state: 'hold', reason: 'runtime_conflict',
1030
+ next_action: 'inspect_findings_and_resolve_or_split', lease_state: 'revoked',
1031
+ detail: { findings: findings.filter((finding) => finding.todo_ids.includes(affectedTaskId)) } };
1032
+ current = await changeIntervention(runDir, current, affectedTaskId, intervention);
1033
+ const stoppedIntake = project(current.events, current.meta).intakes
1034
+ .find((entry) => entry.task_id === affectedTaskId);
1035
+ if (stoppedIntake.worker && !stoppedIntake.worker.stopped) {
1036
+ await signalAttachedWorker(stoppedIntake, 'SIGSTOP');
1037
+ current = await appendEvent(runDir, current, buildEvent({
1038
+ events: current.events, meta: current.meta, kind: 'worker_stopped', taskId: affectedTaskId,
1039
+ payload: { reason: 'runtime_conflict' },
1040
+ }));
1041
+ }
1042
+ }
1043
+ fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings });
1044
+ }
1045
+ current = await appendEvent(runDir, current, buildEvent({
1046
+ events: current.events, meta: current.meta, kind: 'task_accepted', taskId,
1047
+ payload: { done_event_digest: done.event_digest,
1048
+ checkpoint_digest: checkpoint.checkpoint_digest, checkpoint,
1049
+ head_sha: checkpoint.diff.head_sha },
1050
+ }));
1051
+
1052
+ // 先着taskがacceptedになった後、planning conflictだけで待っていた後着を再投影する。
1053
+ state = project(current.events, current.meta);
1054
+ const planningMember = activeMember(store, current.meta.plan_key);
1055
+ for (const waiting of state.intakes.filter((entry) => (
1056
+ entry.accepted === null
1057
+ && ['planning_conflict', 'planning_precedence'].includes(entry.intervention.reason)
1058
+ ))) {
1059
+ const artifact = await readTodoIndependenceArtifact({ repoRoot, planKey: current.meta.plan_key });
1060
+ if (planningConstraint(artifact, waiting.task_id, state, planningMember) !== null) continue;
1061
+ const intervention = { state: 'none', reason: null, next_action: null,
1062
+ lease_state: 'granted', detail: { released_by_accepted_task: taskId } };
1063
+ current = await changeIntervention(runDir, current, waiting.task_id, intervention);
1064
+ const resumed = project(current.events, current.meta).intakes
1065
+ .find((entry) => entry.task_id === waiting.task_id);
1066
+ if (resumed.worker?.stopped) {
1067
+ await signalAttachedWorker(resumed, 'SIGCONT');
1068
+ current = await appendEvent(runDir, current, buildEvent({
1069
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId: waiting.task_id,
1070
+ payload: { released_by_accepted_task: taskId },
1071
+ }));
1072
+ }
1073
+ }
1074
+ const result = { schema: 'lattice.pull_accept_result.v1', outcome: 'accepted',
1075
+ already_accepted: false, task_id: taskId, head_sha: checkpoint.diff.head_sha,
1076
+ done_event_digest: done.event_digest, checkpoint_digest: checkpoint.checkpoint_digest };
1077
+ result.result_digest = digestArtifact(result); return result;
1078
+ } finally { await lock.release(); }
1079
+ }
1080
+
1081
+ export async function observePullRun(runDir) {
1082
+ const stored = await readPullStore(runDir);
1083
+ const state = project(stored.events, stored.meta);
1084
+ const output = {
1085
+ schema: 'lattice.pull_run_observation.v1', run_id: state.run_id,
1086
+ plan_key: state.plan_key, plan_version: state.plan_version,
1087
+ intakes: state.intakes.map(publicIntake),
1088
+ hold_count: state.intakes.filter((entry) => entry.intervention.state === 'hold').length,
1089
+ accepted_count: state.intakes.filter((entry) => entry.accepted !== null).length,
1090
+ closed: state.closed, event_count: stored.events.length,
1091
+ events_digest: digestArtifact(stored.events.map((event) => event.event_digest)),
1092
+ };
1093
+ output.result_digest = digestArtifact(output); return output;
1094
+ }
1095
+
1096
+ export async function statusPullRun(runDir) {
1097
+ const observed = await observePullRun(runDir);
1098
+ const output = { ...observed, schema: 'lattice.pull_run_status.v1' };
1099
+ output.result_digest = digestArtifact(output); return output;
1100
+ }
1101
+
1102
+ export async function interventionPullTask(runDir, taskId) {
1103
+ const stored = await readPullStore(runDir);
1104
+ const intake = project(stored.events, stored.meta).intakes.find((entry) => entry.task_id === taskId);
1105
+ if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
1106
+ const output = { schema: 'lattice.pull_intervention.v1', run_id: stored.meta.run_id,
1107
+ task_id: taskId, ...structuredClone(intake.intervention),
1108
+ worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false };
1109
+ output.result_digest = digestArtifact(output); return output;
1110
+ }
1111
+
1112
+ async function repositoryLanding(repoRoot, acceptedReceipts) {
1113
+ const push = await run('git', ['rev-parse', '--symbolic-full-name', '@{push}'], repoRoot, [0, 128]);
1114
+ let pushState = 'no_upstream'; let pushRef = null; let unpushedCommits = null;
1115
+ if (push.code === 0 && push.stdout.trim()) {
1116
+ pushRef = push.stdout.trim(); pushState = 'tracked';
1117
+ const count = await run('git', ['rev-list', '--count', `${pushRef}..HEAD`], repoRoot);
1118
+ unpushedCommits = Number(count.stdout.trim());
1119
+ }
1120
+ const remote = await run('git', ['remote'], repoRoot);
1121
+ const remoteNames = remote.stdout.split('\n').filter(Boolean);
1122
+ let defaultBranchRef = null;
1123
+ if (remoteNames.length === 1) {
1124
+ const symbolic = await run('git', ['symbolic-ref', '--quiet', `refs/remotes/${remoteNames[0]}/HEAD`], repoRoot, [0, 1]);
1125
+ if (symbolic.code === 0) defaultBranchRef = symbolic.stdout.trim();
1126
+ }
1127
+ const receipts = [];
1128
+ for (const receipt of acceptedReceipts) {
1129
+ let landingState = 'default_branch_unresolved'; let landed = false;
1130
+ if (defaultBranchRef !== null) {
1131
+ const ancestry = await run('git', ['merge-base', '--is-ancestor', receipt.head_sha, defaultBranchRef], repoRoot, [0, 1]);
1132
+ landed = ancestry.code === 0; landingState = landed ? 'landed' : 'not_landed';
1133
+ }
1134
+ receipts.push({ ...receipt, landing_state: landingState, landed });
1135
+ }
1136
+ return { receipts, repository: {
1137
+ default_branch_state: defaultBranchRef === null ? 'unresolved' : 'resolved',
1138
+ default_branch_ref: defaultBranchRef, push_state: pushState, push_ref: pushRef, unpushed_commits: unpushedCommits,
1139
+ } };
1140
+ }
1141
+
1142
+ export async function landingPullRun({ repoRoot, runDir }) {
1143
+ const stored = await readPullStore(runDir);
1144
+ const state = project(stored.events, stored.meta);
1145
+ const accepted = state.intakes.filter((entry) => entry.accepted !== null).map((entry) => ({
1146
+ todo_id: entry.task_id,
1147
+ receipt_id: entry.accepted.done_event_digest,
1148
+ head_sha: entry.accepted.head_sha,
1149
+ }));
1150
+ const { receipts, repository } = await repositoryLanding(repoRoot, accepted);
1151
+ const output = { schema: 'lattice.run_landing_report.v1', run_id: state.run_id,
1152
+ landed: receipts.length > 0 && receipts.every((entry) => entry.landed),
1153
+ accepted_receipts: receipts, repository };
1154
+ output.result_digest = digestArtifact(output); return output;
1155
+ }
1156
+
1157
+ export async function closePullRun({ repoRoot, runDir }) {
1158
+ const lock = await acquirePullLock(runDir, 'close', `close-${randomUUID()}`);
1159
+ try {
1160
+ let current = await readPullStore(runDir);
1161
+ const state = project(current.events, current.meta);
1162
+ if (state.closed) {
1163
+ const output = { schema: 'lattice.pull_run_close_result.v1', outcome: 'closed',
1164
+ run_id: state.run_id, already_closed: true, intake_count: state.intakes.length,
1165
+ landing: await landingPullRun({ repoRoot, runDir }) };
1166
+ output.result_digest = digestArtifact(output); return output;
1167
+ }
1168
+ const incomplete = state.intakes.filter((entry) => entry.accepted === null);
1169
+ if (incomplete.length > 0) {
1170
+ fail('RUN_NOT_COMPLETE', 'pending/hold中intakeが残っている', {
1171
+ task_ids: incomplete.map((entry) => entry.task_id),
1172
+ });
1173
+ }
1174
+ current = await appendEvent(runDir, current, buildEvent({
1175
+ events: current.events, meta: current.meta, kind: 'run_closed',
1176
+ payload: { accepted_task_ids: state.intakes.map((entry) => entry.task_id).sort() },
1177
+ }));
1178
+ const output = { schema: 'lattice.pull_run_close_result.v1', outcome: 'closed',
1179
+ run_id: state.run_id, already_closed: false, intake_count: state.intakes.length,
1180
+ landing: await landingPullRun({ repoRoot, runDir }) };
1181
+ output.result_digest = digestArtifact(output); return output;
1182
+ } finally { await lock.release(); }
1183
+ }
1184
+
1185
+ export async function listPullRunEntry(runDir) {
1186
+ const stored = await readPullStore(runDir);
1187
+ const state = project(stored.events, stored.meta);
1188
+ return { closed: state.closed, entry: {
1189
+ run_id: state.run_id, run_ref: null, base_sha: null,
1190
+ executor_adapter: null, selection: 'pull', plan_key: state.plan_key,
1191
+ } };
1192
+ }