@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.
- package/package.json +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- 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
|
+
}
|