@quolu/lattice 0.52.2 → 0.52.4

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