@quolu/lattice 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * 実行時競合の早期警報(ADR 0143)。
3
+ *
4
+ * 競合はこれまで、checkpointを撮った瞬間にしか見つからなかった。checkpointに周期は無く、
5
+ * 実質workerが完了するまで誰も気づかない。holdで捨てる作業量の正体はこの窓である。
6
+ *
7
+ * **警報はfindingではない。** findingの契約はcheckpoint digestを必須にしており、それは
8
+ * findingが「事後に再読して再導出できる主張」であることを担保している。fs eventは取りこぼすし
9
+ * (FSEventsのcoalesce、inotifyのキュー溢れ)、事後再読もできない。よってここが出すのは
10
+ * 「早くcheckpointを撮って確かめろ」という引き金だけであり、判定の正本はcheckpointのままである。
11
+ *
12
+ * この非対称が安全性の根拠になる。警報は**何かを抑制することが無く、早める方向にしか働かない**。
13
+ * 取りこぼしても、今日と同じタイミング(完了時・hold時)で必ず捕まる——保証は一切緩まない。
14
+ *
15
+ * 判定述語はcheckpoint findingと同一(`coveredBy`を共有する)。書き込みイベントのpathから
16
+ * worktree rootを剥がせばrepo相対pathになり、誰がやったかはrootが決める。プロセス帰属は要らない。
17
+ *
18
+ * **ただしそれはworktreeとTODOが1対1の時だけ成り立つ。** 帰属をrootだけに預けているので、
19
+ * 複数TODOが同じrootを共有する構成では書き手を特定できない。そこでは監視を張らない
20
+ * (`syncSentinelWatches`)——見えないものを見えるふりにしない。
21
+ */
22
+
23
+ import { watch } from 'node:fs';
24
+ import { lstat } from 'node:fs/promises';
25
+ import path from 'node:path';
26
+
27
+ import { selfDigest } from './runtime-contracts.mjs';
28
+ import { coveredBy } from './runtime-diff-observer.mjs';
29
+
30
+ /** 警報の種別。findingのkindとは別空間にする——findingへ昇格するのはprobeを通った後だけである。 */
31
+ export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_scope_warning']);
32
+
33
+ /**
34
+ * 監視から外すrepo相対prefix。
35
+ *
36
+ * `.git`と`.lattice`は道具自身の書き込みで、作業の成果ではない。`node_modules`は隔離実行が
37
+ * 共有mountとして張る場所であり(`seam-apply.mjs`と同じ規律)、worktreeを跨いで同じ絶対pathを
38
+ * 指しうるので、pathの一致を競合と読むと必ず誤る。
39
+ */
40
+ export const DEFAULT_IO_EXCLUDES = Object.freeze(['.git/', '.lattice/', 'node_modules/']);
41
+
42
+ const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
43
+
44
+ function plainRecord(value) {
45
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ /** worktree rootを剥がしてrepo相対pathにする。rootの外を指すものはnull。 */
49
+ export function relativeToRoot(root, absolutePath) {
50
+ if (typeof root !== 'string' || typeof absolutePath !== 'string') return null;
51
+ const relative = path.relative(root, absolutePath);
52
+ if (relative.length === 0) return null;
53
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) return null;
54
+ return relative.split(path.sep).join('/');
55
+ }
56
+
57
+ /** 監視対象外か。prefix一致で見る。 */
58
+ export function isExcludedPath(relativePath, excludes = DEFAULT_IO_EXCLUDES) {
59
+ return excludes.some((prefix) => relativePath === prefix.replace(/\/$/u, '')
60
+ || relativePath.startsWith(prefix));
61
+ }
62
+
63
+ /**
64
+ * 1件の書き込み観測を警報へ分類する。純関数。
65
+ *
66
+ * checkpoint findingの2述語をそのまま1 pathへ適用する:
67
+ * - 他のrunning TODOの宣言scopeに入るpathへ書いた → `io_overlap_warning`
68
+ * - 自分の宣言scopeの外へ書いた → `io_scope_warning`
69
+ *
70
+ * @returns {{warnings: Array<{kind: string, todo_ids: string[], path: string}>}}
71
+ */
72
+ export function classifyIoObservation(options = {}) {
73
+ const { todoId, relativePath, packets, runningTodoIds } = options;
74
+ if (typeof todoId !== 'string' || typeof relativePath !== 'string'
75
+ || !plainRecord(packets) || !Array.isArray(runningTodoIds)) {
76
+ throw new TypeError('classifyIoObservation optionsが不正');
77
+ }
78
+ const packet = packets[todoId];
79
+ // 宣言が無いTODOの観測は判定できない。分からないものを「競合なし」へ丸めない——
80
+ // ただし警報は正本ではないので、ここでrunを止めることもしない。
81
+ if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
82
+ return { warnings: [] };
83
+ }
84
+
85
+ const warnings = [];
86
+ if (!coveredBy(packet.scope.writes, relativePath)) {
87
+ warnings.push({ kind: 'io_scope_warning', todo_ids: [todoId], path: relativePath });
88
+ }
89
+ for (const otherId of [...runningTodoIds].sort(compareText)) {
90
+ if (otherId === todoId) continue;
91
+ const other = packets[otherId];
92
+ if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) continue;
93
+ if (coveredBy(other.scope.writes, relativePath)) {
94
+ warnings.push({
95
+ kind: 'io_overlap_warning',
96
+ todo_ids: [todoId, otherId].sort(compareText),
97
+ path: relativePath,
98
+ });
99
+ }
100
+ }
101
+ return { warnings };
102
+ }
103
+
104
+ /** 同じ事実を何度も報告しない。1 epochで`(kind, todo集合, path)`ごとに1回に畳む。 */
105
+ function warningKey(warning) {
106
+ return `${warning.kind}\0${warning.todo_ids.join(',')}\0${warning.path}`;
107
+ }
108
+
109
+ /**
110
+ * running bindingのworktreeを監視し、警報を`onWarning`へ渡す。
111
+ *
112
+ * `fs.watch(root, {recursive: true})`だけを使う。Node 22の標準機能で、macOSとLinuxの双方で
113
+ * 動き、新しいruntime依存を持ち込まない。取りこぼしは仕様である——正本はcheckpointであり、
114
+ * ここは早めるためだけに在る。
115
+ *
116
+ * @param {object} options
117
+ * @param {Function} options.onWarning 警報1件ごとに呼ばれる。非同期でよい。
118
+ * @param {Function} [options.watchFactory] test用の差し替え口。既定は`fs.watch`。
119
+ */
120
+ export function createIoSentinel(options = {}) {
121
+ const {
122
+ packets = {}, onWarning, excludes = DEFAULT_IO_EXCLUDES, watchFactory = watch,
123
+ } = options;
124
+ if (typeof onWarning !== 'function') throw new TypeError('onWarningが不正');
125
+
126
+ /** todo_id -> { root, watcher } */
127
+ const watched = new Map();
128
+ const reported = new Set();
129
+ let closed = false;
130
+
131
+ const deliver = async (todoId, relativePath, absolutePath) => {
132
+ if (closed) return;
133
+ // 実測(macOS)では、監視callbackはdirectoryイベントと、監視対象自身の名前を持つ
134
+ // 実在しないentryまで配ってくる。どちらもcheckpoint diffのentryにはならないので、
135
+ // そのまま警報にすると「警報は出たがcheckpointでは競合にならない」ずれが生まれる。
136
+ // 判定述語をcheckpointと揃えるために、**いま実在する通常file**だけを観測として扱う。
137
+ //
138
+ // 削除は早期警報の対象から外れる。checkpointは削除をentryとして持つので取り逃しでは
139
+ // なく、早く気づけないだけである——警報は早めるためだけに在るという原則どおり。
140
+ try {
141
+ const stat = await lstat(absolutePath);
142
+ if (!stat.isFile()) return;
143
+ } catch {
144
+ return;
145
+ }
146
+ const { warnings } = classifyIoObservation({
147
+ todoId, relativePath, packets, runningTodoIds: [...watched.keys()],
148
+ });
149
+ for (const warning of warnings) {
150
+ const key = warningKey(warning);
151
+ if (reported.has(key)) continue;
152
+ reported.add(key);
153
+ await onWarning(structuredClone(warning));
154
+ }
155
+ };
156
+
157
+ return {
158
+ /** 監視を開始する。既に同じtodoを見ているなら張り替える。 */
159
+ watchBinding({ todoId, worktreePath }) {
160
+ if (closed) return false;
161
+ if (typeof todoId !== 'string' || typeof worktreePath !== 'string') return false;
162
+ this.unwatchBinding(todoId);
163
+ let watcher;
164
+ try {
165
+ watcher = watchFactory(worktreePath, { recursive: true }, (_event, filename) => {
166
+ if (filename === null || filename === undefined) return;
167
+ const absolute = path.resolve(worktreePath, String(filename));
168
+ const relative = relativeToRoot(worktreePath, absolute);
169
+ if (relative === null || isExcludedPath(relative, excludes)) return;
170
+ // 監視callbackは同期契約なので、配送の失敗をここで投げない。
171
+ // 警報が落ちてもcheckpointが正本なので、runの判定は壊れない。
172
+ void Promise.resolve(deliver(todoId, relative, absolute)).catch(() => {});
173
+ });
174
+ } catch {
175
+ // 監視を張れない環境(platform制約、権限、root不在)でrunを止めない。
176
+ return false;
177
+ }
178
+ if (typeof watcher?.on === 'function') watcher.on('error', () => {});
179
+ watched.set(todoId, { root: worktreePath, watcher });
180
+ return true;
181
+ },
182
+
183
+ unwatchBinding(todoId) {
184
+ const entry = watched.get(todoId);
185
+ if (entry === undefined) return false;
186
+ try { entry.watcher.close(); } catch { /* 既に閉じている */ }
187
+ watched.delete(todoId);
188
+ return true;
189
+ },
190
+
191
+ /** epochを跨いだら減衰の記憶を捨てる。新しい版では同じpathでも改めて報告する。 */
192
+ resetEpoch() {
193
+ reported.clear();
194
+ },
195
+
196
+ watchedTodoIds() {
197
+ return [...watched.keys()].sort(compareText);
198
+ },
199
+
200
+ close() {
201
+ closed = true;
202
+ for (const todoId of [...watched.keys()]) this.unwatchBinding(todoId);
203
+ reported.clear();
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * running中で、かつ**書き手を特定できる**TODOだけを監視するようsentinelを合わせる(ADR 0143)。
210
+ *
211
+ * 監視rootは`executor_dispatched`の`direct_os_observation_binding.worktree_path`から取る。
212
+ * これがTODO→絶対pathの唯一の耐久carrierである。
213
+ *
214
+ * **rootを共有しているTODOは監視しない。** sentinelの帰属はrootだけで決まり、プロセス帰属を
215
+ * 持たない。同じrootで2つ以上が走っている構成では、1件の書き込みが両方のwatcherへ配られ、
216
+ * どちらが書いたか観測から言えない——それを警報にすると、無実のTODOへ「他人のscopeへ書いた」
217
+ * と主張することになる。管理daemonのscripted構成が実際にこれで、全TODOが同じrepo rootを指す。
218
+ *
219
+ * 見えないものを見えるふりにしない。共有rootでは早期警報が成立しないというだけであり、
220
+ * 競合の判定は従来どおりcheckpointが完全に担う——保証は1つも減らない。
221
+ *
222
+ * @param {object} options
223
+ * @param {object|null} options.sentinel `createRunSentinel`の戻り値
224
+ * @param {string[]} options.runningTodoIds いまrunningのTODO
225
+ * @param {Function} options.rootOf todo_id -> worktree root(未束縛はundefined)
226
+ */
227
+ export function syncSentinelWatches({ sentinel, runningTodoIds, rootOf } = {}) {
228
+ if (sentinel === null || sentinel === undefined) return;
229
+ if (!Array.isArray(runningTodoIds) || typeof rootOf !== 'function') {
230
+ throw new TypeError('syncSentinelWatches optionsが不正');
231
+ }
232
+ const occupants = new Map();
233
+ for (const todoId of runningTodoIds) {
234
+ const root = rootOf(todoId);
235
+ if (typeof root !== 'string' || root.length === 0) continue;
236
+ occupants.set(root, (occupants.get(root) ?? 0) + 1);
237
+ }
238
+ const attributable = runningTodoIds.filter((todoId) => occupants.get(rootOf(todoId)) === 1);
239
+ const watched = new Set(sentinel.watchedTodoIds());
240
+ for (const todoId of watched) {
241
+ if (!attributable.includes(todoId)) sentinel.unwatchBinding(todoId);
242
+ }
243
+ for (const todoId of attributable) {
244
+ // 張り替えは監視を一度落とすので、既に見ているものへは触らない。
245
+ if (watched.has(todoId)) continue;
246
+ sentinel.watchBinding({ todoId, worktreePath: rootOf(todoId) });
247
+ }
248
+ }
249
+
250
+ /** `LATTICE_IO_SENTINEL`の解釈。既定は警報を出す。`off`で完全に無効。 */
251
+ export function ioSentinelMode(env = process.env) {
252
+ const raw = String(env.LATTICE_IO_SENTINEL ?? '').trim().toLowerCase();
253
+ return ['off', 'warn'].includes(raw) ? raw : 'warn';
254
+ }
255
+
256
+ /**
257
+ * run用のsentinelを作る。無効なら`null`を返す——呼び出し側は分岐を1つ持つだけでよい。
258
+ *
259
+ * 監視を張れない環境でrunを止めないのと同じ理由で、ここで例外を投げない。sentinelは
260
+ * 速さのための付加物であり、これが無くてもrunの判定は今までどおり成立する。
261
+ */
262
+ export function createRunSentinel({ packets, onWarning, env = process.env } = {}) {
263
+ if (ioSentinelMode(env) === 'off') return null;
264
+ return createIoSentinel({ packets, onWarning });
265
+ }
266
+
267
+ /**
268
+ * 警報が実在の重なりだったかをcheckpointで確かめる(ADR 0143の二段目)。
269
+ *
270
+ * 警報だけで止めると、書いて消したtempでも全workerを止めてしまう。かといって警報を
271
+ * findingへ昇格させることもできない——findingは事後に再読して再導出できる主張でなければ
272
+ * ならず、fs eventはそれを満たさない。
273
+ *
274
+ * よって間に**probe**を挟む。関与worktreeを無停止でcheckpointし、当該pathがdiffに
275
+ * 残っていれば実在、消えていればtransientとする。probeが撮ったcheckpointはgitから読んだ
276
+ * 本物のdiffなので、そのままfindingの証拠になる——契約を1つも緩めずに済む。
277
+ *
278
+ * @param {object} options
279
+ * @param {object} options.warning `classifyIoObservation`が返した警報
280
+ * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
281
+ * @returns {{outcome: 'observed'|'transient', writers: string[]}}
282
+ */
283
+ export function probeIoWarning({ warning, checkpointsByTodo } = {}) {
284
+ if (!plainRecord(warning) || typeof warning.path !== 'string'
285
+ || !Array.isArray(warning.todo_ids) || !plainRecord(checkpointsByTodo)) {
286
+ throw new TypeError('probeIoWarning optionsが不正');
287
+ }
288
+ const writers = [];
289
+ for (const todoId of [...warning.todo_ids].sort(compareText)) {
290
+ const entries = checkpointsByTodo[todoId]?.diff?.entries;
291
+ if (!Array.isArray(entries)) continue;
292
+ if (entries.some((entry) => entry?.path === warning.path)) writers.push(todoId);
293
+ }
294
+ // 重なりを主張する警報は、当該pathが**実際に変更として残っている**ことを要件にする。
295
+ // scope警報は自分1人の話なので、自分のdiffに残っていれば実在である。
296
+ const required = warning.kind === 'io_overlap_warning' ? 2 : 1;
297
+ return { outcome: writers.length >= required ? 'observed' : 'transient', writers };
298
+ }
299
+
300
+ /**
301
+ * 警報kind → finding kind。probeを通った警報だけがこの写像に乗る。
302
+ *
303
+ * 述語が同一(`coveredBy`)なので、写像は1対1に決まる。ここで新しい種類を発明しない——
304
+ * 発明すると、警報経由のfindingだけ既存の処置(請求項7の直列化、請求項8のseam変換)が
305
+ * 効かない種類になってしまう。
306
+ */
307
+ const WARNING_FINDING_KIND = Object.freeze({
308
+ io_overlap_warning: 'observed_write_conflict',
309
+ io_scope_warning: 'scope_violation',
310
+ });
311
+
312
+ /**
313
+ * findingを縛るcheckpointの持ち主を選ぶ。
314
+ *
315
+ * scope警報は自分1人の話なので当人。重なりは**他人の宣言scopeへ書いた側**——producerの
316
+ * 述語がそう定義されているからで、ここで別の選び方をすると再導出が一致しない。
317
+ * 双方が同じpathを宣言している場合は両方が資格を持つので、昇順で決めて揺らさない。
318
+ */
319
+ function selectEscalationAnchor({ warning, writers, packets }) {
320
+ const wrote = new Set(writers);
321
+ const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
322
+ if (!wrote.has(todoId)) return false;
323
+ if (warning.kind === 'io_scope_warning') return true;
324
+ return warning.todo_ids.some((otherId) => {
325
+ if (otherId === todoId) return false;
326
+ const other = packets[otherId];
327
+ return plainRecord(other) && plainRecord(other.scope) && Array.isArray(other.scope.writes)
328
+ && coveredBy(other.scope.writes, warning.path);
329
+ });
330
+ });
331
+ return qualified[0] ?? null;
332
+ }
333
+
334
+ /**
335
+ * probeを通った警報を、既存のhold経路が受け取れるfinding candidateへ写す(ADR 0143の三段目)。
336
+ *
337
+ * **新しい判定はここに1つも無い。** 出すのはcandidate——つまり「主張」だけであり、それを
338
+ * findingへ昇格させてよいかは既存の`finding_record`が再導出して決める。だから早期警報が
339
+ * 短くするのは気づくまでの時間だけで、通す関門は1つも減らない。
340
+ *
341
+ * findingは1つのcheckpointへ縛られるので、**anchorの選び方が効く**。`detectCheckpointFindings`は
342
+ * anchorのdiffと「他TODOの宣言write」からしか重なりを導かないので、anchorは
343
+ * **他人の宣言scopeへ書いた側(offender)**でなければならない。警報自身は`todo_ids`を
344
+ * 昇順一意で持つ設計上どちらがofferedかを覚えていないので、ここで宣言から選び直す。
345
+ * 選び間違えるとproducerが同じfindingを再導出できず、正しい警報が形式の都合で落ちる。
346
+ *
347
+ * @param {object} options
348
+ * @param {object} options.warning `classifyIoObservation`が返した警報
349
+ * @param {object} options.probe `probeIoWarning`が返した判定
350
+ * @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
351
+ * @param {object} options.packets todo_id -> executor packet(宣言scopeの出所)
352
+ * @returns {null|{anchor_todo_id: string, checkpoint_digest: string, writers: string[], candidate: object}}
353
+ */
354
+ export function buildIoEscalation({ warning, probe, checkpointsByTodo, packets } = {}) {
355
+ if (!plainRecord(warning) || !plainRecord(probe) || !plainRecord(checkpointsByTodo)
356
+ || !plainRecord(packets) || !Array.isArray(probe.writers)) {
357
+ throw new TypeError('buildIoEscalation optionsが不正');
358
+ }
359
+ if (probe.outcome !== 'observed') return null;
360
+ const proposedKind = WARNING_FINDING_KIND[warning.kind];
361
+ if (proposedKind === undefined) return null;
362
+ const anchorTodoId = selectEscalationAnchor({ warning, writers: probe.writers, packets });
363
+ if (anchorTodoId === null) return null;
364
+ const checkpoint = checkpointsByTodo[anchorTodoId];
365
+ if (!plainRecord(checkpoint) || !/^[0-9a-f]{64}$/u.test(checkpoint.checkpoint_digest ?? '')) {
366
+ return null;
367
+ }
368
+ const candidate = {
369
+ schema: 'lattice.runtime_finding_candidate.v1',
370
+ proposed_kind: proposedKind,
371
+ todo_ids: [...warning.todo_ids].sort(compareText),
372
+ path: warning.path,
373
+ resource_id: null,
374
+ evidence_digests: [checkpoint.checkpoint_digest],
375
+ candidate_digest: '',
376
+ };
377
+ candidate.candidate_digest = selfDigest(candidate, 'candidate_digest');
378
+ return {
379
+ anchor_todo_id: anchorTodoId,
380
+ checkpoint_digest: checkpoint.checkpoint_digest,
381
+ writers: [...probe.writers].sort(compareText),
382
+ candidate,
383
+ };
384
+ }
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
9
9
  import { selfDigest } from './runtime-contracts.mjs';
10
10
  import { captureWorktreeDiff } from './runtime-diff-observer.mjs';
11
+ import { pidsOwningSocketPath, socketPathsOwnedByPid } from './runtime-socket-owner.mjs';
11
12
  import { createDirectOsProcessObserver as createDirectOsProcessObserverV2 } from './runtime-direct-os-observer.mjs';
12
13
  import {
13
14
  armStagedWriteLease,
@@ -877,10 +878,10 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
877
878
  }
878
879
  const endpointInfo = await lstat(handshakeSocket);
879
880
  if (!endpointInfo.isSocket() || endpointInfo.isSymbolicLink()) fail('ADAPTER_LAUNCH_INVALID', 'existing endpointがsocketでない');
880
- let ownerOutput;
881
- try { ({ stdout: ownerOutput } = await execFileAsync('/usr/sbin/lsof', ['-t', handshakeSocket], { encoding: 'utf8' })); }
881
+ let ownerPids;
882
+ try { ownerPids = await pidsOwningSocketPath(handshakeSocket); }
882
883
  catch { fail('ADAPTER_LAUNCH_INVALID', 'existing endpoint ownerを観測できない'); }
883
- endpointOwnerPids = new Set(ownerOutput.trim().split(/\s+/u).filter(Boolean).map(Number));
884
+ endpointOwnerPids = new Set(ownerPids);
884
885
  if (endpointOwnerPids.size !== 1 || [...endpointOwnerPids].some((pid) => !Number.isSafeInteger(pid) || pid < 1)) {
885
886
  fail('ADAPTER_LAUNCH_INVALID', 'existing endpoint ownerが一意でない');
886
887
  }
@@ -900,10 +901,10 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
900
901
  try { if ((await lstat(controllerSocketPath)).isSocket()) break; } catch (error) { if (error?.code !== 'ENOENT') throw error; }
901
902
  await new Promise((resolve) => setTimeout(resolve, 20));
902
903
  }
903
- let controllerOwnerOutput;
904
- try { ({ stdout: controllerOwnerOutput } = await execFileAsync('/usr/sbin/lsof', ['-t', controllerSocketPath], { encoding: 'utf8' })); }
904
+ let controllerOwnerPids;
905
+ try { controllerOwnerPids = await pidsOwningSocketPath(controllerSocketPath); }
905
906
  catch { fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'existing controller socket ownerを観測できない'); }
906
- const persistentOwners = new Set(controllerOwnerOutput.trim().split(/\s+/u).filter(Boolean).map(Number));
907
+ const persistentOwners = new Set(controllerOwnerPids);
907
908
  if (persistentOwners.size !== 1 || !persistentOwners.has(controllerDescriptor.pid)) {
908
909
  fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'persistent controller socket ownerとdescriptor PID不一致');
909
910
  }
@@ -1019,12 +1020,10 @@ export async function sendRuntimeControlRequest({ socketPath, request, timeoutMs
1019
1020
  try { observedSupervisor = await observeManagedProcessStartIdentity(descriptor.pid); }
1020
1021
  catch { fail('RUN_NOT_MANAGED', 'supervisor process不在'); }
1021
1022
  if (canonicalizeArtifact(observedSupervisor) !== canonicalizeArtifact(descriptor.process_start_identity)) fail('RUN_NOT_MANAGED', 'supervisor PID/start identity不一致');
1022
- let lsofOutput;
1023
- try {
1024
- ({ stdout: lsofOutput } = await execFileAsync('/usr/sbin/lsof', ['-a', '-p', String(descriptor.pid), '-U', '-F', 'fn'], { encoding: 'utf8' }));
1025
- } catch { fail('RUN_NOT_MANAGED', 'supervisor socket owner観測失敗'); }
1026
- const ownsExactSocket = lsofOutput.split('\n')
1027
- .filter((line) => line.startsWith('n')).map((line) => line.slice(1))
1023
+ let ownedSocketPaths;
1024
+ try { ownedSocketPaths = await socketPathsOwnedByPid(descriptor.pid); }
1025
+ catch { fail('RUN_NOT_MANAGED', 'supervisor socket owner観測失敗'); }
1026
+ const ownsExactSocket = ownedSocketPaths
1028
1027
  .some((name) => name === absoluteSocket || name === socketRef);
1029
1028
  if (!ownsExactSocket) fail('RUN_NOT_MANAGED', 'supervisor PIDがcontrol socket inodeを所有しない');
1030
1029
  return new Promise((resolve, reject) => {
@@ -1088,10 +1087,10 @@ export async function sendRuntimeActivationRequest({ socketPath, request, expect
1088
1087
  try { observedBootstrapIdentity = await observeManagedProcessStartIdentity(expectedPid); }
1089
1088
  catch { fail('RUN_NOT_MANAGED', 'activation bootstrap process不在'); }
1090
1089
  if (canonicalizeArtifact(observedBootstrapIdentity) !== canonicalizeArtifact(expectedProcessStartIdentity)) fail('RUN_NOT_MANAGED', 'activation bootstrap PID/start identity不一致');
1091
- let lsofOutput;
1092
- try { ({ stdout: lsofOutput } = await execFileAsync('/usr/sbin/lsof', ['-a', '-p', String(expectedPid), '-U', '-F', 'fn'], { encoding: 'utf8' })); }
1090
+ let bootstrapSocketPaths;
1091
+ try { bootstrapSocketPaths = await socketPathsOwnedByPid(expectedPid); }
1093
1092
  catch { fail('RUN_NOT_MANAGED', 'activation socket owner観測失敗'); }
1094
- if (!lsofOutput.split('\n').filter((line) => line.startsWith('n')).map((line) => line.slice(1))
1093
+ if (!bootstrapSocketPaths
1095
1094
  .some((name) => name === absoluteSocket || name === socketRef)) fail('RUN_NOT_MANAGED', 'bootstrap PIDがcontrol socketを所有しない');
1096
1095
  const originalCwd = process.cwd();
1097
1096
  process.chdir(runDir);
@@ -1143,13 +1142,15 @@ export async function prepareManagedSupervisorRestart({ runDir }) {
1143
1142
  const socketInfo = await lstat(socketPath).catch(() => null);
1144
1143
  if (socketInfo !== null) {
1145
1144
  if (!socketInfo.isSocket() || socketInfo.isSymbolicLink()) fail('RUN_NOT_MANAGED', 'stale socket path差替え');
1146
- try {
1147
- const { stdout } = await execFileAsync('/usr/sbin/lsof', [socketPath], { encoding: 'utf8' });
1148
- if (stdout.trim().length > 0) fail('RUN_BUSY', 'control socketは別processが所有中');
1149
- } catch (error) {
1145
+ // 所有者が居るなら消さない。観測できなければ「誰も居ない」へ丸めず観測失敗として止める
1146
+ // lsofはヒット無しでexit 1を返すので、この2つを区別する必要がある)。
1147
+ let staleOwners;
1148
+ try { staleOwners = await pidsOwningSocketPath(socketPath); }
1149
+ catch (error) {
1150
1150
  if (error instanceof ManagedRuntimeError) throw error;
1151
- if (!(error?.code === 1 && String(error?.stdout ?? '').trim() === '')) fail('RUN_NOT_MANAGED', 'stale socket owner観測失敗');
1151
+ fail('RUN_NOT_MANAGED', 'stale socket owner観測失敗');
1152
1152
  }
1153
+ if (staleOwners.length > 0) fail('RUN_BUSY', 'control socketは別processが所有中');
1153
1154
  await rm(socketPath, { force: true });
1154
1155
  }
1155
1156
  return { previousDescriptorDigest: descriptor.descriptor_digest };
@@ -1220,13 +1221,13 @@ export async function serveRuntimeControlSocket({ socketPath, handler }) {
1220
1221
  try {
1221
1222
  const existing = await lstat(socketPath);
1222
1223
  if (!existing.isSocket()) fail('RUN_NOT_MANAGED', 'control.sockがsocketでない');
1223
- try {
1224
- const { stdout } = await execFileAsync('/usr/sbin/lsof', [socketPath], { encoding: 'utf8' });
1225
- if (stdout.trim().length > 0) fail('RUN_BUSY', 'control.sockはlive processが所有する');
1226
- } catch (error) {
1224
+ let liveOwners;
1225
+ try { liveOwners = await pidsOwningSocketPath(socketPath); }
1226
+ catch (error) {
1227
1227
  if (error instanceof ManagedRuntimeError) throw error;
1228
- if (!(error?.code === 1 && String(error?.stdout ?? '').trim() === '')) fail('RUN_NOT_MANAGED', 'control.sock owner観測失敗');
1228
+ fail('RUN_NOT_MANAGED', 'control.sock owner観測失敗');
1229
1229
  }
1230
+ if (liveOwners.length > 0) fail('RUN_BUSY', 'control.sockはlive processが所有する');
1230
1231
  await rm(socketPath, { force: true });
1231
1232
  } catch (error) {
1232
1233
  if (error?.code !== 'ENOENT') throw error;
@@ -31,6 +31,7 @@ import {
31
31
  } from './runtime-contracts.mjs';
32
32
  import { validateSupervisorWriteGate } from './runtime-gate-store.mjs';
33
33
  import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
34
+ import { scriptedWorktreeId, scriptedWorktreePath } from './runtime-scripted-worktree.mjs';
34
35
 
35
36
  const MAX_DOCUMENT_BYTES = 8_388_608;
36
37
  const SHA256 = /^[0-9a-f]{64}$/u;
@@ -538,12 +539,24 @@ export async function createScriptedAdapterController({
538
539
  }, 'response_digest');
539
540
  }
540
541
  await readAndValidateGate(canonicalRunDir, writeLease);
542
+ // canonical repoではなく自分の木へ書く。共有rootでは、書き込みの帰属をrootから
543
+ // 決められないので早期警報もcheckpoint判定も成立しない。木はsupervisorが
544
+ // dispatch前に用意する(監視を張るのがdispatchより前でなければ観測を取り逃す)。
545
+ const worktreePath = scriptedWorktreePath({ runDir: canonicalRunDir, packet });
546
+ try {
547
+ const info = await lstat(worktreePath);
548
+ if (!info.isDirectory()) throw new TypeError('not a directory');
549
+ } catch {
550
+ fail('SCRIPTED_EXECUTION_FAILED', 'supervisorが用意したworktreeが無い', {
551
+ worktree_path: worktreePath,
552
+ });
553
+ }
541
554
  const { observedDiff, checkpointDigest } = await executePacket({
542
555
  packet,
543
- repoRoot: canonicalRepoRoot,
556
+ repoRoot: await realpath(worktreePath),
544
557
  });
545
558
  const executorHandle = `scripted-${packet.packet_digest.slice(0, 24)}`;
546
- const worktreeId = `scripted-wt-${packet.packet_digest.slice(0, 24)}`;
559
+ const worktreeId = scriptedWorktreeId(packet);
547
560
  const receipt = sign({
548
561
  schema: 'lattice.executor_receipt.v1',
549
562
  receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 管理runtimeのworker worktree配置(ADR 0143 / io-sentinel st-004)。
3
+ *
4
+ * **なぜ配置を契約にするのか。** supervisor daemonとscripted controllerは別プロセスであり、
5
+ * 両者が同じ木を指す必要がある。daemonは「どこを監視し、どこのdiffを撮るか」を知らなければ
6
+ * ならず、controllerは「どこへ書くか」を知らなければならない。dispatch応答は`worktree_id`しか
7
+ * 運ばないので、pathを応答へ足すか、配置そのものを契約にするかの二択になる。ここでは後者を採る
8
+ * ——**daemonが監視を張れるのはdispatchの前**であり、応答を待っていては書き込みを取り逃すからである。
9
+ *
10
+ * これが無かった頃、全TODOのbindingは同じrepo rootを指していた。sentinelの帰属はrootだけで
11
+ * 決まるので、共有rootでは誰が書いたかを言えず、早期警報は成立しなかった。checkpoint判定も
12
+ * 同じ理由で帰属を決められない。**worktreeとTODOの1対1は、装置の前提であって最適化ではない。**
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { mkdir, realpath, rm } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ const GIT_SHA1 = /^[0-9a-f]{40}$/u;
20
+
21
+ function fail(reason) {
22
+ throw new TypeError(`scripted worktree契約違反: ${reason}`);
23
+ }
24
+
25
+ function run(command, args, cwd, { allowExitCodes = [0] } = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
28
+ const stdout = [];
29
+ const stderr = [];
30
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
31
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
32
+ child.once('error', reject);
33
+ child.once('close', (code, signal) => {
34
+ if (allowExitCodes.includes(code) && signal === null) {
35
+ resolve(Object.assign(Buffer.concat(stdout), { code }));
36
+ } else {
37
+ reject(new TypeError(
38
+ `${command} ${args[0]} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`,
39
+ ));
40
+ }
41
+ });
42
+ });
43
+ }
44
+
45
+ /** worktreeの名前。packet digestへ縛るので、同じpacketには必ず同じ木が対応する。 */
46
+ export function scriptedWorktreeId(packet) {
47
+ const digest = packet?.packet_digest;
48
+ if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/u.test(digest)) {
49
+ fail('packet_digestが不正');
50
+ }
51
+ return `scripted-wt-${digest.slice(0, 24)}`;
52
+ }
53
+
54
+ /** run store配下のworktree path。runの寿命と一致するので、後片付けもrunに閉じる。 */
55
+ export function scriptedWorktreePath({ runDir, packet } = {}) {
56
+ if (typeof runDir !== 'string' || runDir.length === 0) fail('runDirが不正');
57
+ return path.join(runDir, 'worktrees', scriptedWorktreeId(packet), 'tree');
58
+ }
59
+
60
+ /**
61
+ * packetのbase shaでworktreeを用意する。既に在るなら作り直さない。
62
+ *
63
+ * run storeはgitignore済みなので(`requireIgnoredRunStore`)、ここに木を切っても
64
+ * canonical repoの`git status`は汚れない。
65
+ */
66
+ export async function ensureScriptedWorktree({ repoRoot, runDir, packet } = {}) {
67
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) fail('repoRootが不正');
68
+ if (!GIT_SHA1.test(packet?.base_sha ?? '')) fail('packet.base_shaが不正');
69
+ const worktreePath = scriptedWorktreePath({ runDir, packet });
70
+ try {
71
+ return await realpath(worktreePath);
72
+ } catch (error) {
73
+ if (error?.code !== 'ENOENT') throw error;
74
+ }
75
+ await mkdir(path.dirname(worktreePath), { recursive: true, mode: 0o700 });
76
+ await run('git', ['worktree', 'add', '--detach', worktreePath, packet.base_sha], repoRoot);
77
+ return realpath(worktreePath);
78
+ }
79
+
80
+ /**
81
+ * runが持つworktreeを全て畳む。
82
+ *
83
+ * 取り残すと`git worktree list`へ積み上がり、次のrunがprune前提の状態を引き継ぐ。
84
+ * 失敗は握り潰さず、畳めなかったpathを添えて返す——呼び出し側が記録できる形にしておく。
85
+ */
86
+ export async function removeScriptedWorktrees({ repoRoot, runDir } = {}) {
87
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) fail('repoRootが不正');
88
+ if (typeof runDir !== 'string' || runDir.length === 0) fail('runDirが不正');
89
+ const root = path.join(runDir, 'worktrees');
90
+ const listed = await run('git', ['worktree', 'list', '--porcelain'], repoRoot);
91
+ const residual = [];
92
+ for (const line of listed.toString('utf8').split('\n')) {
93
+ if (!line.startsWith('worktree ')) continue;
94
+ const target = line.slice('worktree '.length);
95
+ if (target !== root && !target.startsWith(`${root}${path.sep}`)) continue;
96
+ try {
97
+ await run('git', ['worktree', 'remove', '--force', target], repoRoot);
98
+ } catch {
99
+ residual.push(target);
100
+ }
101
+ }
102
+ if (residual.length === 0) await rm(root, { recursive: true, force: true });
103
+ return { removed_root: root, residual_paths: residual };
104
+ }