@quolu/lattice 0.52.0 → 0.52.2

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