@quolu/lattice 0.52.0 → 0.52.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +147 -147
- package/README.ja.md +355 -355
- package/README.md +258 -258
- package/bin/lattice-mcp.mjs +0 -0
- package/bin/lattice-scripted-adapter.mjs +0 -0
- package/bin/lattice-scripted-worker.mjs +0 -0
- package/bin/lattice-work-order-adapter.mjs +0 -0
- package/bin/lattice.mjs +0 -0
- package/docs/bridge-setup.md +132 -132
- package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
- package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
- package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
- package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
- package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
- package/package.json +103 -103
- package/sensor/LICENSE +21 -21
- package/sensor/NOTICE +19 -19
- package/sensor/dist/bin/lattice-sensor.js +9 -9
- package/sensor/dist/db/index.js +24 -24
- package/sensor/dist/db/migrations.js +41 -41
- package/sensor/dist/db/queries.js +164 -164
- package/sensor/dist/db/schema.sql +205 -205
- package/sensor/dist/directory.js +5 -5
- package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
- package/sensor/dist/mcp/liveness-watchdog.js +53 -53
- package/sensor/dist/mcp/server-instructions.js +95 -95
- package/sensor/package.json +56 -56
- 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-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/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1053
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +697 -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 -1502
- 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 -520
- package/src/runtime-cli.mjs +4588 -4584
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -600
- 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 -162
- 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 -477
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -290
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -834
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1188
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
- 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 -1167
- 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 +3141 -3126
- 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 -625
- 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 +4322 -4314
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
|
@@ -1,391 +1,391 @@
|
|
|
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_undeclared_write_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_undeclared_write_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_undeclared_write_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。**重なりだけがhold経路へ乗る。**
|
|
302
|
-
*
|
|
303
|
-
* 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
|
|
304
|
-
* 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
|
|
305
|
-
* 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
|
|
306
|
-
* 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
|
|
307
|
-
*
|
|
308
|
-
* よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
|
|
309
|
-
* それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
|
|
310
|
-
* 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
|
|
311
|
-
*
|
|
312
|
-
* 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
|
|
313
|
-
* 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
|
|
314
|
-
*/
|
|
315
|
-
const WARNING_FINDING_KIND = Object.freeze({
|
|
316
|
-
io_overlap_warning: 'observed_write_conflict',
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* findingを縛るcheckpointの持ち主を選ぶ。
|
|
321
|
-
*
|
|
322
|
-
* scope警報は自分1人の話なので当人。重なりは**他人の宣言scopeへ書いた側**——producerの
|
|
323
|
-
* 述語がそう定義されているからで、ここで別の選び方をすると再導出が一致しない。
|
|
324
|
-
* 双方が同じpathを宣言している場合は両方が資格を持つので、昇順で決めて揺らさない。
|
|
325
|
-
*/
|
|
326
|
-
function selectEscalationAnchor({ warning, writers, packets }) {
|
|
327
|
-
const wrote = new Set(writers);
|
|
328
|
-
const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
|
|
329
|
-
if (!wrote.has(todoId)) return false;
|
|
330
|
-
if (warning.kind === 'io_undeclared_write_warning') return true;
|
|
331
|
-
return warning.todo_ids.some((otherId) => {
|
|
332
|
-
if (otherId === todoId) return false;
|
|
333
|
-
const other = packets[otherId];
|
|
334
|
-
return plainRecord(other) && plainRecord(other.scope) && Array.isArray(other.scope.writes)
|
|
335
|
-
&& coveredBy(other.scope.writes, warning.path);
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
return qualified[0] ?? null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* probeを通った警報を、既存のhold経路が受け取れるfinding candidateへ写す(ADR 0143の三段目)。
|
|
343
|
-
*
|
|
344
|
-
* **新しい判定はここに1つも無い。** 出すのはcandidate——つまり「主張」だけであり、それを
|
|
345
|
-
* findingへ昇格させてよいかは既存の`finding_record`が再導出して決める。だから早期警報が
|
|
346
|
-
* 短くするのは気づくまでの時間だけで、通す関門は1つも減らない。
|
|
347
|
-
*
|
|
348
|
-
* findingは1つのcheckpointへ縛られるので、**anchorの選び方が効く**。`detectCheckpointFindings`は
|
|
349
|
-
* anchorのdiffと「他TODOの宣言write」からしか重なりを導かないので、anchorは
|
|
350
|
-
* **他人の宣言scopeへ書いた側(offender)**でなければならない。警報自身は`todo_ids`を
|
|
351
|
-
* 昇順一意で持つ設計上どちらがofferedかを覚えていないので、ここで宣言から選び直す。
|
|
352
|
-
* 選び間違えるとproducerが同じfindingを再導出できず、正しい警報が形式の都合で落ちる。
|
|
353
|
-
*
|
|
354
|
-
* @param {object} options
|
|
355
|
-
* @param {object} options.warning `classifyIoObservation`が返した警報
|
|
356
|
-
* @param {object} options.probe `probeIoWarning`が返した判定
|
|
357
|
-
* @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
|
|
358
|
-
* @param {object} options.packets todo_id -> executor packet(宣言scopeの出所)
|
|
359
|
-
* @returns {null|{anchor_todo_id: string, checkpoint_digest: string, writers: string[], candidate: object}}
|
|
360
|
-
*/
|
|
361
|
-
export function buildIoEscalation({ warning, probe, checkpointsByTodo, packets } = {}) {
|
|
362
|
-
if (!plainRecord(warning) || !plainRecord(probe) || !plainRecord(checkpointsByTodo)
|
|
363
|
-
|| !plainRecord(packets) || !Array.isArray(probe.writers)) {
|
|
364
|
-
throw new TypeError('buildIoEscalation optionsが不正');
|
|
365
|
-
}
|
|
366
|
-
if (probe.outcome !== 'observed') return null;
|
|
367
|
-
const proposedKind = WARNING_FINDING_KIND[warning.kind];
|
|
368
|
-
if (proposedKind === undefined) return null;
|
|
369
|
-
const anchorTodoId = selectEscalationAnchor({ warning, writers: probe.writers, packets });
|
|
370
|
-
if (anchorTodoId === null) return null;
|
|
371
|
-
const checkpoint = checkpointsByTodo[anchorTodoId];
|
|
372
|
-
if (!plainRecord(checkpoint) || !/^[0-9a-f]{64}$/u.test(checkpoint.checkpoint_digest ?? '')) {
|
|
373
|
-
return null;
|
|
374
|
-
}
|
|
375
|
-
const candidate = {
|
|
376
|
-
schema: 'lattice.runtime_finding_candidate.v1',
|
|
377
|
-
proposed_kind: proposedKind,
|
|
378
|
-
todo_ids: [...warning.todo_ids].sort(compareText),
|
|
379
|
-
path: warning.path,
|
|
380
|
-
resource_id: null,
|
|
381
|
-
evidence_digests: [checkpoint.checkpoint_digest],
|
|
382
|
-
candidate_digest: '',
|
|
383
|
-
};
|
|
384
|
-
candidate.candidate_digest = selfDigest(candidate, 'candidate_digest');
|
|
385
|
-
return {
|
|
386
|
-
anchor_todo_id: anchorTodoId,
|
|
387
|
-
checkpoint_digest: checkpoint.checkpoint_digest,
|
|
388
|
-
writers: [...probe.writers].sort(compareText),
|
|
389
|
-
candidate,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
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_undeclared_write_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_undeclared_write_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_undeclared_write_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。**重なりだけがhold経路へ乗る。**
|
|
302
|
+
*
|
|
303
|
+
* 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
|
|
304
|
+
* 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
|
|
305
|
+
* 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
|
|
306
|
+
* 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
|
|
307
|
+
*
|
|
308
|
+
* よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
|
|
309
|
+
* それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
|
|
310
|
+
* 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
|
|
311
|
+
*
|
|
312
|
+
* 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
|
|
313
|
+
* 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
|
|
314
|
+
*/
|
|
315
|
+
const WARNING_FINDING_KIND = Object.freeze({
|
|
316
|
+
io_overlap_warning: 'observed_write_conflict',
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* findingを縛るcheckpointの持ち主を選ぶ。
|
|
321
|
+
*
|
|
322
|
+
* scope警報は自分1人の話なので当人。重なりは**他人の宣言scopeへ書いた側**——producerの
|
|
323
|
+
* 述語がそう定義されているからで、ここで別の選び方をすると再導出が一致しない。
|
|
324
|
+
* 双方が同じpathを宣言している場合は両方が資格を持つので、昇順で決めて揺らさない。
|
|
325
|
+
*/
|
|
326
|
+
function selectEscalationAnchor({ warning, writers, packets }) {
|
|
327
|
+
const wrote = new Set(writers);
|
|
328
|
+
const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
|
|
329
|
+
if (!wrote.has(todoId)) return false;
|
|
330
|
+
if (warning.kind === 'io_undeclared_write_warning') return true;
|
|
331
|
+
return warning.todo_ids.some((otherId) => {
|
|
332
|
+
if (otherId === todoId) return false;
|
|
333
|
+
const other = packets[otherId];
|
|
334
|
+
return plainRecord(other) && plainRecord(other.scope) && Array.isArray(other.scope.writes)
|
|
335
|
+
&& coveredBy(other.scope.writes, warning.path);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
return qualified[0] ?? null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* probeを通った警報を、既存のhold経路が受け取れるfinding candidateへ写す(ADR 0143の三段目)。
|
|
343
|
+
*
|
|
344
|
+
* **新しい判定はここに1つも無い。** 出すのはcandidate——つまり「主張」だけであり、それを
|
|
345
|
+
* findingへ昇格させてよいかは既存の`finding_record`が再導出して決める。だから早期警報が
|
|
346
|
+
* 短くするのは気づくまでの時間だけで、通す関門は1つも減らない。
|
|
347
|
+
*
|
|
348
|
+
* findingは1つのcheckpointへ縛られるので、**anchorの選び方が効く**。`detectCheckpointFindings`は
|
|
349
|
+
* anchorのdiffと「他TODOの宣言write」からしか重なりを導かないので、anchorは
|
|
350
|
+
* **他人の宣言scopeへ書いた側(offender)**でなければならない。警報自身は`todo_ids`を
|
|
351
|
+
* 昇順一意で持つ設計上どちらがofferedかを覚えていないので、ここで宣言から選び直す。
|
|
352
|
+
* 選び間違えるとproducerが同じfindingを再導出できず、正しい警報が形式の都合で落ちる。
|
|
353
|
+
*
|
|
354
|
+
* @param {object} options
|
|
355
|
+
* @param {object} options.warning `classifyIoObservation`が返した警報
|
|
356
|
+
* @param {object} options.probe `probeIoWarning`が返した判定
|
|
357
|
+
* @param {object} options.checkpointsByTodo todo_id -> `captureWorktreeDiff`の戻り値
|
|
358
|
+
* @param {object} options.packets todo_id -> executor packet(宣言scopeの出所)
|
|
359
|
+
* @returns {null|{anchor_todo_id: string, checkpoint_digest: string, writers: string[], candidate: object}}
|
|
360
|
+
*/
|
|
361
|
+
export function buildIoEscalation({ warning, probe, checkpointsByTodo, packets } = {}) {
|
|
362
|
+
if (!plainRecord(warning) || !plainRecord(probe) || !plainRecord(checkpointsByTodo)
|
|
363
|
+
|| !plainRecord(packets) || !Array.isArray(probe.writers)) {
|
|
364
|
+
throw new TypeError('buildIoEscalation optionsが不正');
|
|
365
|
+
}
|
|
366
|
+
if (probe.outcome !== 'observed') return null;
|
|
367
|
+
const proposedKind = WARNING_FINDING_KIND[warning.kind];
|
|
368
|
+
if (proposedKind === undefined) return null;
|
|
369
|
+
const anchorTodoId = selectEscalationAnchor({ warning, writers: probe.writers, packets });
|
|
370
|
+
if (anchorTodoId === null) return null;
|
|
371
|
+
const checkpoint = checkpointsByTodo[anchorTodoId];
|
|
372
|
+
if (!plainRecord(checkpoint) || !/^[0-9a-f]{64}$/u.test(checkpoint.checkpoint_digest ?? '')) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const candidate = {
|
|
376
|
+
schema: 'lattice.runtime_finding_candidate.v1',
|
|
377
|
+
proposed_kind: proposedKind,
|
|
378
|
+
todo_ids: [...warning.todo_ids].sort(compareText),
|
|
379
|
+
path: warning.path,
|
|
380
|
+
resource_id: null,
|
|
381
|
+
evidence_digests: [checkpoint.checkpoint_digest],
|
|
382
|
+
candidate_digest: '',
|
|
383
|
+
};
|
|
384
|
+
candidate.candidate_digest = selfDigest(candidate, 'candidate_digest');
|
|
385
|
+
return {
|
|
386
|
+
anchor_todo_id: anchorTodoId,
|
|
387
|
+
checkpoint_digest: checkpoint.checkpoint_digest,
|
|
388
|
+
writers: [...probe.writers].sort(compareText),
|
|
389
|
+
candidate,
|
|
390
|
+
};
|
|
391
|
+
}
|