@quolu/lattice 0.32.0 → 0.34.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.
Files changed (64) hide show
  1. package/README.ja.md +11 -1
  2. package/README.md +11 -2
  3. package/bin/lattice.mjs +12 -1
  4. package/package.json +1 -1
  5. package/sensor/dist/bin/lattice-sensor.js +72 -4
  6. package/sensor/dist/db/migrations.d.ts +1 -1
  7. package/sensor/dist/db/migrations.d.ts.map +1 -1
  8. package/sensor/dist/db/migrations.js +42 -2
  9. package/sensor/dist/db/migrations.js.map +1 -1
  10. package/sensor/dist/db/queries.d.ts +18 -0
  11. package/sensor/dist/db/queries.d.ts.map +1 -1
  12. package/sensor/dist/db/queries.js +76 -9
  13. package/sensor/dist/db/queries.js.map +1 -1
  14. package/sensor/dist/db/schema.sql +10 -1
  15. package/sensor/dist/extraction/extraction-version.d.ts +1 -1
  16. package/sensor/dist/extraction/extraction-version.d.ts.map +1 -1
  17. package/sensor/dist/extraction/extraction-version.js +5 -1
  18. package/sensor/dist/extraction/extraction-version.js.map +1 -1
  19. package/sensor/dist/extraction/index.d.ts +2 -0
  20. package/sensor/dist/extraction/index.d.ts.map +1 -1
  21. package/sensor/dist/extraction/index.js +38 -3
  22. package/sensor/dist/extraction/index.js.map +1 -1
  23. package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
  24. package/sensor/dist/extraction/tree-sitter.js +89 -16
  25. package/sensor/dist/extraction/tree-sitter.js.map +1 -1
  26. package/sensor/dist/index.d.ts +8 -1
  27. package/sensor/dist/index.d.ts.map +1 -1
  28. package/sensor/dist/index.js +9 -0
  29. package/sensor/dist/index.js.map +1 -1
  30. package/sensor/dist/resolution/import-resolver.d.ts +11 -0
  31. package/sensor/dist/resolution/import-resolver.d.ts.map +1 -1
  32. package/sensor/dist/resolution/import-resolver.js +13 -5
  33. package/sensor/dist/resolution/import-resolver.js.map +1 -1
  34. package/sensor/dist/resolution/index.d.ts.map +1 -1
  35. package/sensor/dist/resolution/index.js +11 -0
  36. package/sensor/dist/resolution/index.js.map +1 -1
  37. package/sensor/dist/resolution/types.d.ts +4 -0
  38. package/sensor/dist/resolution/types.d.ts.map +1 -1
  39. package/sensor/dist/types.d.ts +22 -0
  40. package/sensor/dist/types.d.ts.map +1 -1
  41. package/src/artifact-contracts.mjs +3 -0
  42. package/src/cli-help.mjs +6 -0
  43. package/src/rc3-scripted-campaign.mjs +3 -1
  44. package/src/runtime-cli.mjs +280 -36
  45. package/src/runtime-contracts.mjs +1 -1
  46. package/src/runtime-control-store.mjs +24 -1
  47. package/src/runtime-decision-verifier.mjs +1 -1
  48. package/src/runtime-diff-observer.mjs +2 -2
  49. package/src/runtime-front-end.mjs +47 -13
  50. package/src/runtime-hold-recompile.mjs +31 -2
  51. package/src/runtime-io-sentinel.mjs +16 -9
  52. package/src/runtime-managed-supervisor.mjs +32 -3
  53. package/src/runtime-multi-epoch-store.mjs +2 -2
  54. package/src/runtime-projection.mjs +27 -0
  55. package/src/runtime-scripted-adapter-controller.mjs +22 -1
  56. package/src/runtime-seam-resolve.mjs +156 -13
  57. package/src/runtime-seam-treatment.mjs +2 -1
  58. package/src/seam-apply.mjs +101 -4
  59. package/src/seam-cost.mjs +322 -0
  60. package/src/seam-gate.mjs +146 -0
  61. package/src/seam-rewrite.mjs +102 -16
  62. package/src/seam-verification.mjs +26 -3
  63. package/src/sensor-adapter.mjs +6 -1
  64. package/src/todo-cli.mjs +55 -0
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { constants as fsConstants } from 'node:fs';
4
4
  import {
5
5
  lstat,
@@ -139,6 +139,20 @@ const KNOWN_ADAPTERS = Object.freeze(['scripted', 'isolated-worktree', 'actual-a
139
139
  * 超えたら`rejected`として理由ごとjournalへ残す(黙って見送らない)。
140
140
  */
141
141
  const IO_ESCALATION_LOCK_TIMEOUT_MS = 15_000;
142
+ /**
143
+ * 係争pathから資源idを導出する(front-endと同じ形)。
144
+ *
145
+ * front-endは`owns`から`own-<kind>-<sha256(target)先頭16>`を作る。path競合の直列化資源は
146
+ * その形でなければ、後継planの競合辺が実ownershipの資源と別物になってしまう。
147
+ * **形を1箇所で決める。** 別の形で作ると、同じpathが別の資源に見える。
148
+ */
149
+ function ownedResourceId(kind, target) {
150
+ return `own-${kind}-${createHash('sha256').update(target, 'utf8').digest('hex').slice(0, 16)}`;
151
+ }
152
+
153
+ function ownedPathResourceId(pathValue) {
154
+ return ownedResourceId('path', pathValue);
155
+ }
142
156
  /** 走行中workerの完了を待つ上限と間隔。待てないと走行中の観測が成立しない。 */
143
157
  const SCRIPTED_OBSERVE_TIMEOUT_MS = 120_000;
144
158
  const SCRIPTED_OBSERVE_POLL_MS = 20;
@@ -743,6 +757,80 @@ async function readScriptedControllerReceipt({
743
757
  return receipt;
744
758
  }
745
759
 
760
+ /**
761
+ * hold裁定をworker processへ反映する(請求項7・8の再開側)。
762
+ *
763
+ * **barrierは全workerを止める。** 静止の証明はrun全体に対して要るからである。hold裁定は
764
+ * 止めた相手を`hold_set`と`continue_set`へ分け、後者は「影響閉包の外なので作業を捨てない」
765
+ * と判定されたものである。
766
+ *
767
+ * **carry-overは「一度も止まらない」ではない。** rebindは静止を要求する
768
+ * (`write_enabled === false`)ので、holdの直後に再開すると後継epochへ束ね直せず
769
+ * `EPOCH_REBIND_INCOMPLETE`で落ちる。止まり、繋がれ、そこで動き出すのが正しい順序である。
770
+ * よってこの関数を呼ぶのはrebindが済んだ後だけとする。
771
+ *
772
+ * pidだけで再開しない。pidは再利用されるので、start identityを照合して記録と同じprocessで
773
+ * あることを確かめる——別のprocessをSIGCONTするのは、止めるのと同じくらい危険である。
774
+ */
775
+ async function resumeContinuedWorkers({ events, todoIds }) {
776
+ if (!Array.isArray(todoIds) || todoIds.length === 0) return { resumed: [], skipped: [] };
777
+ const resumed = [];
778
+ const skipped = [];
779
+ for (const todoId of todoIds) {
780
+ const dispatch = events.findLast((event) => event.kind === 'executor_dispatched'
781
+ && event.subject?.kind === 'todo' && event.subject.ref === todoId);
782
+ const binding = dispatch?.payload?.direct_os_observation_binding;
783
+ const pid = binding?.process_pid;
784
+ const recorded = binding?.process_start_identity?.identity_digest;
785
+ if (!Number.isSafeInteger(pid) || typeof recorded !== 'string') continue;
786
+ // pidは再利用される。記録と同じprocessであることを確かめてから再開する——
787
+ // 別のprocessをSIGCONTするのは、止めるのと同じくらい危険である。
788
+ const observed = await observeManagedProcessStartIdentity(pid).catch(() => null);
789
+ if (observed === null || observed.identity_digest !== recorded) {
790
+ skipped.push({ todo_id: todoId, reason: observed === null ? 'process不在' : 'start identity不一致' });
791
+ continue;
792
+ }
793
+ try { process.kill(pid, 'SIGCONT'); resumed.push(todoId); } catch {
794
+ skipped.push({ todo_id: todoId, reason: 'SIGCONT失敗' });
795
+ }
796
+ }
797
+ return { resumed, skipped };
798
+ }
799
+
800
+ /**
801
+ * runが起こしたworker processを、耐久記録から回収する。
802
+ *
803
+ * **記録が正本である。** 誰を起こしたかは`executor_dispatched`の
804
+ * `direct_os_observation_binding`が持っており、controllerが落ちていても残る。
805
+ *
806
+ * pidだけで殺さない。pidは再利用されるので、start identityを取り直して記録と一致した
807
+ * ものだけを止める——一致しなければ、それは別のprocessである。
808
+ *
809
+ * SIGKILLで送る。holdで止めたworkerはSIGSTOP状態にあり、SIGTERMは継続されるまで
810
+ * 配送されない。停止したまま放置されたprocessは自分では終われない。
811
+ */
812
+ async function reapRunWorkerProcesses({ events }) {
813
+ const reaped = [];
814
+ const skipped = [];
815
+ const seen = new Set();
816
+ for (const event of events) {
817
+ if (event.kind !== 'executor_dispatched') continue;
818
+ const binding = event.payload?.direct_os_observation_binding;
819
+ const pid = binding?.process_pid;
820
+ const recorded = binding?.process_start_identity?.identity_digest;
821
+ if (!Number.isSafeInteger(pid) || typeof recorded !== 'string' || seen.has(pid)) continue;
822
+ seen.add(pid);
823
+ const observed = await observeManagedProcessStartIdentity(pid).catch(() => null);
824
+ if (observed === null) continue; // 既に居ない
825
+ if (observed.identity_digest !== recorded) {
826
+ skipped.push({ pid, reason: 'start identity不一致(pid再利用)' });
827
+ continue;
828
+ }
829
+ try { process.kill(pid, 'SIGKILL'); reaped.push(pid); } catch { /* 競合で既に消えた */ }
830
+ }
831
+ return { reaped, skipped };
832
+ }
833
+
746
834
  async function driveInitialScriptedManagedEpoch({
747
835
  runDir,
748
836
  repoRoot,
@@ -1130,6 +1218,64 @@ async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, st
1130
1218
  return resolution.lane === 'seam_transform' ? 0 : 1;
1131
1219
  }
1132
1220
 
1221
+ /**
1222
+ * 切断コストの内訳を、記録済みfindingについて投影する(read-only、docs/plan_seam-cost.md)。
1223
+ *
1224
+ * `seam resolve`が隔離worktreeで実変換まで行うのに対し、これは**変換を試す前の安い観測**である。
1225
+ * 「何を共有しているから単純に切れないのか」を数えられる事実として返し、試すかどうかの判断は
1226
+ * 操作するAIに残す。閾値も可否判定も返さない。投影であって記録ではないので、runへ何も書かない。
1227
+ *
1228
+ * symbolの帰属(concern_symbols)は入力で受ける。実行時のwitnessはpath単位の宣言しか持たず、
1229
+ * 係争fileの中で誰がどのsymbolを触るかはAIだけが知っている——ここで推定しない。
1230
+ */
1231
+ async function runSeamProfile({ runDir, repoRoot, findingDigest, inputPath, stdout }) {
1232
+ const committed = await readCommittedEpochStore(runDir);
1233
+ if (committed === null) {
1234
+ throw new CliContractError('RUN_NOT_MANAGED', 'runがmanaged storeへactivateされていない');
1235
+ }
1236
+ const found = await readRuntimeFindingRecord({
1237
+ runDir, findingDigest, planEpoch: committed.pointer.plan_epoch,
1238
+ });
1239
+ if (found.record === null) throw new CliContractError('STALE_FINDING', found.reason);
1240
+ const finding = found.record.finding;
1241
+ if (typeof finding.path !== 'string') {
1242
+ throw new CliContractError('SEAM_PROFILE_UNAVAILABLE',
1243
+ 'path findingではない競合に切断コストの内訳は立たない');
1244
+ }
1245
+
1246
+ const declaration = await readBoundedJson(inputPath, 'seam profile input');
1247
+ const identifier = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
1248
+ const symbols = declaration?.concern_symbols;
1249
+ const todoIds = [...finding.todo_ids].sort();
1250
+ const declaredIds = symbols === null || typeof symbols !== 'object' || Array.isArray(symbols)
1251
+ ? null : Object.keys(symbols).sort();
1252
+ if (declaredIds === null
1253
+ || declaredIds.join('\0') !== todoIds.join('\0')
1254
+ || declaredIds.some((todoId) => !Array.isArray(symbols[todoId])
1255
+ || symbols[todoId].length === 0
1256
+ || !symbols[todoId].every((name) => identifier.test(name)))) {
1257
+ throw new CliContractError('SEAM_PROFILE_UNAVAILABLE',
1258
+ 'concern_symbolsがfindingのtodo集合と一致しない。'
1259
+ + `{"concern_symbols": {${todoIds.map((id) => `"${id}": ["<symbol>"]`).join(', ')}}}の形で渡す`);
1260
+ }
1261
+
1262
+ let sourceText;
1263
+ try {
1264
+ sourceText = await readFile(path.join(repoRoot, finding.path), 'utf8');
1265
+ } catch {
1266
+ throw new CliContractError('SEAM_PROFILE_UNAVAILABLE', `係争fileを読めない: ${finding.path}`);
1267
+ }
1268
+ const { computeSeamCostProfile } = await import('./seam-cost.mjs');
1269
+ const { profile, reasons } = await computeSeamCostProfile({
1270
+ repoRoot, sourcePath: finding.path, sourceText, ownedSymbolsByTask: symbols,
1271
+ });
1272
+ if (profile === null) {
1273
+ throw new CliContractError('SEAM_PROFILE_UNAVAILABLE', reasons.join(',') || 'profile_unavailable');
1274
+ }
1275
+ stdout.write(`${JSON.stringify(profile)}\n`);
1276
+ return 0;
1277
+ }
1278
+
1133
1279
  async function runObserve({ runDir, stdout }) {
1134
1280
  const { events } = await readRunStore(runDir);
1135
1281
  const chain = verifyRunEventChain({ events });
@@ -1179,7 +1325,9 @@ async function runStatus({ runDir, stdout }) {
1179
1325
  if (managed !== null) {
1180
1326
  output.schema = 'lattice.managed_run_status.v1';
1181
1327
  const runtimeProjection = {
1182
- schema: 'lattice.runtime_status_projection.v1',
1328
+ // v2は`treatment_advice`の追加。既定modeが直列化でも変換を試せる場合があることを、
1329
+ // 運転側が見える形にした(ct-003)。
1330
+ schema: 'lattice.runtime_status_projection.v2',
1183
1331
  ...projectRuntimeStatusOverlays({ events }),
1184
1332
  runtime_frozen: managedFrozen,
1185
1333
  };
@@ -1844,8 +1992,6 @@ export async function runManagedSupervisorDaemon({
1844
1992
  * 自分のprocessで作業するので、そのprocessを止めると制御そのものが止まる——止めない限り
1845
1993
  * 証明できず、止めれば応答できない。**別processのexecutorを持つまでこの穴は埋まらない。**
1846
1994
  */
1847
- const canProveQuiescence = () => true;
1848
-
1849
1995
  const attributionIsDistinct = (warning, roots) => {
1850
1996
  const paths = warning.todo_ids.map((todoId) => roots[todoId]);
1851
1997
  if (paths.some((value) => typeof value !== 'string')) return false;
@@ -1974,19 +2120,6 @@ export async function runManagedSupervisorDaemon({
1974
2120
  return decide('skipped',
1975
2121
  'worktree rootを共有する構成では書き手を特定できない(帰属はrootだけで決まる)');
1976
2122
  }
1977
- // **holdは静止の証明を要求する。** 直接OS観測はexecutorのprocessが実際に停止している
1978
- // ことまで確かめるが、executorがcontroller自身のprocessである構成では、止めると
1979
- // 制御そのものが止まるので証明できない。
1980
- //
1981
- // ここで止めるのは、conflictがintakeをfreezeするからである。freezeしてholdが通らない
1982
- // 状態を作ると、runは進むことも畳むこともできなくなる(abandonも静止を要求する)。
1983
- // 止められないと分かっているなら、freezeさせない方が安全側である。判定はcheckpointが
1984
- // 従来どおり担う——早期警報は早めるためだけに在るという原則どおり。
1985
- if (!canProveQuiescence()) {
1986
- return decide('rejected',
1987
- 'executorがcontroller自身のprocessで走っており、静止を証明できない'
1988
- + '(停止すると制御も止まる)。freezeさせるとrunを畳めなくなるので進めない');
1989
- }
1990
2123
  const recorded = await recordCheckpoints(escalation, probed.checkpoints);
1991
2124
  if (!recorded.ok) return decide(recorded.outcome, recorded.detail);
1992
2125
 
@@ -2097,14 +2230,18 @@ export async function runManagedSupervisorDaemon({
2097
2230
  /** 警報の処理を直列化する鎖。`onWarning`はここへ繋ぐだけにする。 */
2098
2231
  let escalationChain = Promise.resolve();
2099
2232
 
2100
- const resolveObservationBinding = async ({ binding }) => {
2233
+ const resolveObservationBinding = async ({ binding, ack }) => {
2234
+ // rebind経路はrunning bindingを持たず、ackだけで来る(`kind: 'rebind'`)。どちらから来ても
2235
+ // 同じdispatch記録へ辿り着けなければ、繋ぎ直す相手を特定できない。
2236
+ const todoId = binding?.todo_id ?? ack?.todo_id;
2237
+ const executorHandle = binding?.executor_handle ?? ack?.executor_handle;
2101
2238
  const events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
2102
2239
  const dispatch = events.findLast((event) => event.kind === 'executor_dispatched'
2103
- && event.subject?.kind === 'todo' && event.subject.ref === binding?.todo_id
2104
- && event.payload?.executor_handle === binding?.executor_handle);
2240
+ && event.subject?.kind === 'todo' && event.subject.ref === todoId
2241
+ && event.payload?.executor_handle === executorHandle);
2105
2242
  const observation = dispatch?.payload?.direct_os_observation_binding;
2106
2243
  if (observation === null || typeof observation !== 'object' || Array.isArray(observation)) {
2107
- throw new ManagedRuntimeError('HOLD_ACKS_INCOMPLETE', `durable Direct OS binding不足: ${binding?.todo_id ?? 'unknown'}`);
2244
+ throw new ManagedRuntimeError('HOLD_ACKS_INCOMPLETE', `durable Direct OS binding不足: ${todoId ?? 'unknown'}`);
2108
2245
  }
2109
2246
  return structuredClone(observation);
2110
2247
  };
@@ -2429,6 +2566,19 @@ export async function runManagedSupervisorDaemon({
2429
2566
  if (finding.plan_epoch !== active.pointer.plan_epoch) {
2430
2567
  throw new ManagedRuntimeError('STALE_FINDING', 'findingはactive epochに属さない');
2431
2568
  }
2569
+ // 競合は2者以上いて初めて競合である。1者しか名指していない観測——誰の領分とも
2570
+ // 重なっていない予測超過——でfreezeすると、抜け道が無い。処置は2つとも2者を要求する
2571
+ // (直列化は`todo_ids.length >= 2`、seam変換は2つの面へ切る)ので、legalなrecompileが
2572
+ // 作れないまま止まる。予測が狭かっただけなので、記録して次のcompileで宣言を実態へ
2573
+ // 合わせるのが正しい応答である。
2574
+ if ((finding.finding?.todo_ids ?? []).length < 2) {
2575
+ throw new ManagedRuntimeError('FINDING_NOT_A_CONFLICT',
2576
+ '1 TODOしか名指していない観測はfreezeへ運べない。'
2577
+ + '予測超過は競合ではないので、記録のまま次のcompileで宣言を観測へ合わせる', {
2578
+ finding_kind: finding.finding?.kind ?? null,
2579
+ todo_ids: [...(finding.finding?.todo_ids ?? [])],
2580
+ });
2581
+ }
2432
2582
  let events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
2433
2583
  const todoId = finding.finding?.todo_ids?.[0] ?? active.bundle.plan.nodes[0].todo_id;
2434
2584
  events.push(buildNextRunEvent({ events, runId: request.request_id, kind: 'conflict_found', planEpoch: active.pointer.plan_epoch,
@@ -2647,20 +2797,40 @@ export async function runManagedSupervisorDaemon({
2647
2797
  !== canonicalizeArtifact(treatment.todo_ids ?? treatment.predecessor_task_ids)) {
2648
2798
  throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'treatmentがhold finding/todo集合と一致しない');
2649
2799
  }
2650
- if (recompileRequest.mode === 'intentional_serial'
2651
- && treatmentFinding.finding.resource_id !== treatment.resource_id) {
2652
- throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'serial resourceがfinding resourceと一致しない');
2653
- }
2654
- if (recompileRequest.mode === 'intentional_serial'
2655
- && !treatment.todo_ids.every((todoId) => {
2656
- const manifest = active.bundle.manifests[todoId];
2657
- return manifest?.resources?.includes(treatment.resource_id)
2658
- || manifest?.state_effects?.some((effect) => effect.resource_id === treatment.resource_id);
2659
- })) {
2660
- throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'serial resourceをfresh ownershipから再導出できない');
2800
+ if (recompileRequest.mode === 'intentional_serial') {
2801
+ // **path競合には資源idが無い。** finding契約はpath形の`resource_id`をnullと定めており、
2802
+ // path由来の資源idはmanifestの`resources`にも載らない(載るのは宣言したbare資源だけ)。
2803
+ // それでもrouteConflictTreatmentはpath競合を直列化レーンへ振る——請求項7の
2804
+ // 「一方を停止し、他方を確定し、停止した方を再開する」がその経路だからである。
2805
+ //
2806
+ // よってpath findingの時は、係争pathからfront-endと同じ形で資源idを導出して照合する。
2807
+ // 導出値との一致を要求するので、資源idを捏造できないという元の保証は保たれる
2808
+ // ——照合先がfindingそのものへ固定されるため、manifest所属を見る必要が無い。
2809
+ const expectedResource = treatmentFinding.finding.path === null
2810
+ ? treatmentFinding.finding.resource_id
2811
+ : ownedPathResourceId(treatmentFinding.finding.path);
2812
+ if (expectedResource !== treatment.resource_id) {
2813
+ throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'serial resourceがfinding resourceと一致しない');
2814
+ }
2815
+ // 宣言済み資源の競合は、従来どおり実ownershipから再導出できることまで要求する。
2816
+ if (treatmentFinding.finding.path === null
2817
+ && !treatment.todo_ids.every((todoId) => {
2818
+ const manifest = active.bundle.manifests[todoId];
2819
+ return manifest?.resources?.includes(treatment.resource_id)
2820
+ || manifest?.state_effects?.some((effect) => effect.resource_id === treatment.resource_id);
2821
+ })) {
2822
+ throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'serial resourceをfresh ownershipから再導出できない');
2823
+ }
2661
2824
  }
2662
2825
  if (recompileRequest.mode === 'seam_split') {
2826
+ // **宣言された面の所有も数える。** `resources`/`state_effects`だけを見ていた間、
2827
+ // path所有はbefore/afterのどちらにも現れず、seam splitが述べる`added`は常に空の
2828
+ // 導出値と突き合わされていた——path競合を切るsplitは原理的に一致しなかった。
2829
+ // 資源idの合成形は`verifySeamSplitSuccessor`と同じ`own-<kind>-<sha16>`にする。
2663
2830
  const ownership = (manifests) => Object.entries(manifests).flatMap(([todoId, manifest]) => [
2831
+ ...(manifest.owns ?? []).map((own) => ({
2832
+ resource_id: ownedResourceId(own.kind, own.target),
2833
+ owner_todo_id: todoId, access_kind: 'own' })),
2664
2834
  ...manifest.resources.map((resourceId) => ({ resource_id: resourceId,
2665
2835
  owner_todo_id: todoId, access_kind: 'own' })),
2666
2836
  ...manifest.state_effects.map((effect) => ({ resource_id: effect.resource_id,
@@ -2674,9 +2844,41 @@ export async function runManagedSupervisorDaemon({
2674
2844
  ].sort((left, right) => canonicalizeArtifact(left).localeCompare(canonicalizeArtifact(right)));
2675
2845
  const difference = (left, right) => left.filter((entry) => !right.some((other) =>
2676
2846
  canonicalizeArtifact(entry) === canonicalizeArtifact(other)));
2677
- const beforeOwnership = ownership(active.bundle.manifests);
2847
+ // **比較の起点は、観測へ合わせた後の宣言である。**
2848
+ //
2849
+ // 実行時に見つかった競合は、片方がその資源を所有していないから起きる。変換はその
2850
+ // 宣言を観測へ合わせてから導出されるので(翻訳段)、splitが述べる遷移も合わせた後の
2851
+ // 状態からのものになる。ここで記録のままのpredecessorと比べると、実行時に見つかった
2852
+ // 競合から作ったsplitは永久に一致しない——請求項8が実行時に届かなくなる。
2853
+ //
2854
+ // 合わせる材料はfindingそのものであり、宣言でも入力でもない。再導出なので、
2855
+ // 呼び出し側が何を主張しても結果は変わらない。
2856
+ const contestedResource = treatmentFinding.finding.path === null
2857
+ ? treatmentFinding.finding.resource_id
2858
+ : ownedPathResourceId(treatmentFinding.finding.path);
2859
+ const observedTodoIds = [...treatmentFinding.finding.todo_ids].sort();
2860
+ const reconciledOwnership = [...ownership(active.bundle.manifests)];
2861
+ for (const todoId of observedTodoIds) {
2862
+ const entry = { resource_id: contestedResource, owner_todo_id: todoId, access_kind: 'own' };
2863
+ if (!reconciledOwnership.some((other) => canonicalizeArtifact(other) === canonicalizeArtifact(entry))) {
2864
+ reconciledOwnership.push(entry);
2865
+ }
2866
+ }
2867
+ const reconciledEdges = [...edges(active.bundle.plan)];
2868
+ for (let left = 0; left < observedTodoIds.length; left += 1) {
2869
+ for (let right = left + 1; right < observedTodoIds.length; right += 1) {
2870
+ const edge = { from_todo_id: observedTodoIds[left], to_todo_id: observedTodoIds[right],
2871
+ kind: 'conflict' };
2872
+ if (!reconciledEdges.some((other) => canonicalizeArtifact(other) === canonicalizeArtifact(edge))) {
2873
+ reconciledEdges.push(edge);
2874
+ }
2875
+ }
2876
+ }
2877
+ const sortEntries = (entries) => [...entries]
2878
+ .sort((left, right) => canonicalizeArtifact(left).localeCompare(canonicalizeArtifact(right)));
2879
+ const beforeOwnership = sortEntries(reconciledOwnership);
2678
2880
  const afterOwnership = ownership(compiled.manifests);
2679
- const beforeEdges = edges(active.bundle.plan);
2881
+ const beforeEdges = sortEntries(reconciledEdges);
2680
2882
  const afterEdges = edges(newPlan);
2681
2883
  const derivedOwnership = { added: difference(afterOwnership, beforeOwnership),
2682
2884
  removed: difference(beforeOwnership, afterOwnership) };
@@ -2825,6 +3027,20 @@ export async function runManagedSupervisorDaemon({
2825
3027
  rebindEvidence.set(todoId, { ...evidence,
2826
3028
  controller_registration_digest: owner.registration.registration_digest });
2827
3029
  }
3030
+ // **ここが再開の位置である。** rebindは静止を要求する(`write_enabled === false`)ので、
3031
+ // holdの直後に再開すると後継epochへ束ね直せない。carry-overは「作業を捨てない」で
3032
+ // あって「一度も止まらない」ではない——barrierで止まり、rebindで新epochへ繋がれ、
3033
+ // そこで初めて動き出す。
3034
+ if (rebound.size > 0) {
3035
+ const resumed = await resumeContinuedWorkers({ events, todoIds: [...rebound] });
3036
+ if (resumed.resumed.length > 0 || resumed.skipped.length > 0) {
3037
+ await appendControl({ run_id: request.request_id, kind: 'workers_resumed',
3038
+ session_nonce_digest: digestArtifact(sessionNonce), payload: {
3039
+ resumed_todo_ids: [...resumed.resumed].sort(),
3040
+ skipped_count: resumed.skipped.length,
3041
+ } });
3042
+ }
3043
+ }
2828
3044
  for (const todoId of core.planDiff.redispatched) {
2829
3045
  const successors = recompileRequest.task_migration.entries
2830
3046
  .find((entry) => entry.predecessor_task_id === todoId)?.successor_task_ids ?? [];
@@ -3002,9 +3218,22 @@ export async function runManagedSupervisorDaemon({
3002
3218
  recordedAt: canonicalNow() })];
3003
3219
  }
3004
3220
  await replaceEventsAtomically(runDir, events);
3005
- // abandonは成果を捨てる決定なので、workerの木も畳む。closeでは畳まない——
3006
- // 木そのものがrunの成果であり、着地させる前に消したら受理した内容が残らない。
3221
+ // **abandonは成果を捨てる決定である。** worker processの終了はcleanupではなく
3222
+ // その決定の一部なので、誰を止めたかを記録へ残す。closeでは何もしない——
3223
+ // 完走したrunのworkerは既にterminalであり、生きていればそれは欠陥である
3224
+ // (`closeRunIfComplete`が全TODO accepted を要求するので、そもそもcloseへ来ない)。
3007
3225
  if (controlRequest.operation === 'abandon') {
3226
+ const reaped = await reapRunWorkerProcesses({ events }).catch(() => null);
3227
+ if (reaped !== null && (reaped.reaped.length > 0 || reaped.skipped.length > 0)) {
3228
+ await appendControl({ run_id: request.request_id, kind: 'worker_processes_terminated',
3229
+ session_nonce_digest: digestArtifact(sessionNonce), payload: {
3230
+ reason: shutdownReason,
3231
+ terminated_pids: [...reaped.reaped].sort((left, right) => left - right),
3232
+ skipped_count: reaped.skipped.length,
3233
+ } });
3234
+ }
3235
+ // 木も畳む。closeでは畳まない——木そのものがrunの成果であり、着地させる前に
3236
+ // 消したら受理した内容が残らない。
3008
3237
  await removeScriptedWorktrees({ repoRoot, runDir }).catch(() => null);
3009
3238
  }
3010
3239
  await appendControl({ run_id: request.request_id, kind: 'supervisor_stopped',
@@ -3528,6 +3757,11 @@ export async function runManagedSupervisorDaemon({
3528
3757
  // 監視fdを残さない。取り残すとtest fixtureの後片付けが重くなる。
3529
3758
  sentinel?.close();
3530
3759
  sentinel = null;
3760
+ // **これは事故処理であって、正しい閉じ方ではない。** 正規の終了はabandon(破棄の決定)か
3761
+ // hold後の再開であり、そのどちらも通らずdaemonが落ちる時だけここへ来る。停止した
3762
+ // workerは自分では終われないので、記録から辿って道連れにする。
3763
+ await readBoundedJson(path.join(runDir, 'events.json'), 'run events')
3764
+ .then((events) => reapRunWorkerProcesses({ events })).catch(() => null);
3531
3765
  if (activationCommitted && activation !== null) {
3532
3766
  await appendControl({ run_id: request.request_id, kind: 'supervisor_stopped',
3533
3767
  session_nonce_digest: digestArtifact(sessionNonce), payload: { signal } });
@@ -3693,6 +3927,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
3693
3927
  requestId: requestIdOverride,
3694
3928
  });
3695
3929
  };
3930
+ } else if (argv.length === 9
3931
+ && argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'profile'
3932
+ && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
3933
+ && argv[5] === '--finding' && /^[0-9a-f]{64}$/u.test(argv[6])
3934
+ && argv[7] === '--input' && typeof argv[8] === 'string' && argv[8].length > 0) {
3935
+ action = async () => {
3936
+ const { repoRoot, runDir } = await resolveRunStore(cwd, argv[4]);
3937
+ return runSeamProfile({ runDir, repoRoot, findingDigest: argv[6],
3938
+ inputPath: path.resolve(cwd, argv[8]), stdout });
3939
+ };
3696
3940
  } else if (argv.length === 9
3697
3941
  && argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
3698
3942
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
@@ -53,7 +53,7 @@ export const RUNTIME_CONFLICT_KINDS = Object.freeze([
53
53
  'observed_write_conflict',
54
54
  'semantic_conflict_unknown',
55
55
  'effect_conflict_unknown',
56
- 'scope_violation',
56
+ 'undeclared_write',
57
57
  'stale_context',
58
58
  ]);
59
59
  export const HOLD_REASON_KINDS = Object.freeze([
@@ -161,7 +161,7 @@ export function validateRuntimeControlEventPayload(kind, value) {
161
161
  // 機械が何かに気づいたのに黙っている状態を残さない。
162
162
  if (kind === 'io_warning_observed') {
163
163
  return exact(value, ['warning_kind', 'todo_ids', 'path', 'probe_outcome', 'warning_digest'])
164
- && ['io_overlap_warning', 'io_scope_warning'].includes(value.warning_kind)
164
+ && ['io_overlap_warning', 'io_undeclared_write_warning'].includes(value.warning_kind)
165
165
  && Array.isArray(value.todo_ids) && value.todo_ids.length >= 1 && value.todo_ids.length <= 256
166
166
  && value.todo_ids.every(identifier)
167
167
  && value.todo_ids.every((id, index) => index === 0 || value.todo_ids[index - 1] < id)
@@ -171,6 +171,29 @@ export function validateRuntimeControlEventPayload(kind, value) {
171
171
  warning_kind: value.warning_kind, todo_ids: value.todo_ids, path: value.path,
172
172
  });
173
173
  }
174
+ // hold裁定のcontinue_setをprocessへ反映した記録(請求項7・8の再開側)。
175
+ // barrierは全workerを止めるので、続けてよいと判定した作業を再開しなければ、
176
+ // 判定と実行が食い違ったままになる。
177
+ if (kind === 'workers_resumed') {
178
+ return exact(value, ['resumed_todo_ids', 'skipped_count'])
179
+ && Array.isArray(value.resumed_todo_ids) && value.resumed_todo_ids.length <= 4096
180
+ && value.resumed_todo_ids.every(identifier)
181
+ && value.resumed_todo_ids.every((id, index) => index === 0
182
+ || value.resumed_todo_ids[index - 1] < id)
183
+ && Number.isSafeInteger(value.skipped_count) && value.skipped_count >= 0;
184
+ }
185
+ // 破棄の決定としてworker processを終了した記録。**cleanupではない。**
186
+ // 停止したworkerの行き先は「再開」か「破棄」の二択であり、破棄を選んだなら
187
+ // 誰を止めたかが残っていなければ、後から何が捨てられたか説明できない。
188
+ if (kind === 'worker_processes_terminated') {
189
+ return exact(value, ['reason', 'terminated_pids', 'skipped_count'])
190
+ && typeof value.reason === 'string' && value.reason.length > 0
191
+ && Array.isArray(value.terminated_pids) && value.terminated_pids.length <= 4096
192
+ && value.terminated_pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)
193
+ && value.terminated_pids.every((pid, index) => index === 0
194
+ || value.terminated_pids[index - 1] < pid)
195
+ && Number.isSafeInteger(value.skipped_count) && value.skipped_count >= 0;
196
+ }
174
197
  // probeを通った警報を既存hold経路へ入れた顛末(ADR 0143の三段目)。
175
198
  // 成否どちらも残す。自動escalationが黙って失敗する状態を作らない——失敗の理由は
176
199
  // `detail`が持ち、これが無いと「警報は出たのにholdが掛かっていない」を後から説明できない。
@@ -150,7 +150,7 @@ export function classifyObservedDiff(options = {}) {
150
150
  const declaredWrites = manifests[todoId].writes ?? [];
151
151
  for (const path of paths) {
152
152
  if (!declaredWriteCovers(declaredWrites, path)) {
153
- findings.push({ kind: 'scope_violation', todo_ids: [todoId], path });
153
+ findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path });
154
154
  }
155
155
  }
156
156
  }
@@ -257,7 +257,7 @@ export function coveredBy(declaredWrites, observedPath) {
257
257
  * cross-bindし、closed conflict分類のfindingを返す(producer側の検出。独立再計算は
258
258
  * runtime-decision-verifierの`classifyObservedDiff`が行う)。
259
259
  *
260
- * - scope_violation: 宣言write scope外へのwrite(offender=当該TODO)。
260
+ * - undeclared_write: 宣言write scope外へのwrite(offender=当該TODO)。
261
261
  * - observed_write_conflict: 他のrunning TODOのdeclared writeとのpath overlap。
262
262
  */
263
263
  export function detectCheckpointFindings(options = {}) {
@@ -276,7 +276,7 @@ export function detectCheckpointFindings(options = {}) {
276
276
  const findings = [];
277
277
  for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
278
278
  if (!coveredBy(packet.scope.writes, entry.path)) {
279
- findings.push({ kind: 'scope_violation', todo_ids: [todoId], path: entry.path });
279
+ findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path: entry.path });
280
280
  }
281
281
  }
282
282
  for (const otherId of [...runningTodoIds].sort()) {
@@ -247,6 +247,25 @@ function affectedPayload(raw, expectPath) {
247
247
  return plainRecord(payload) ? payload : null;
248
248
  }
249
249
 
250
+ /**
251
+ * 収集済みsensor evidenceから、あるpathのaffected testを読む。
252
+ *
253
+ * 実行時の翻訳段(宣言を観測へ合わせる)が同じ観測を必要とする。payloadの形の解釈をあちらへ
254
+ * 書き直すと、driftを判定する規則と、driftを起こさない宣言を作る規則が別々に育つ。
255
+ *
256
+ * @returns {string[]|null} 観測できなければnull(空配列と区別する)
257
+ */
258
+ export function affectedTestsFromEvidence({ sensorEvidence, querySet, path: target } = {}) {
259
+ const queries = (querySet?.queries ?? [])
260
+ .filter((query) => query.operation === 'affected' && query.target === target);
261
+ if (queries.length !== 1) return null;
262
+ const outcome = (sensorEvidence?.outcomes ?? [])
263
+ .find((entry) => entry.query_id === queries[0].id);
264
+ if (!plainRecord(outcome) || outcome.status !== 'ready') return null;
265
+ const payload = affectedPayload(outcome.raw, target);
266
+ return Array.isArray(payload?.affectedTests) ? [...payload.affectedTests].sort(compareText) : null;
267
+ }
268
+
250
269
  function affectedTarget(raw, expectPath) {
251
270
  if (!plainRecord(raw) || !Array.isArray(raw.targets)) return null;
252
271
  const entry = raw.targets.find((candidate) => (
@@ -486,28 +505,43 @@ export function compileRuntimePlanV1(options = {}) {
486
505
  }
487
506
 
488
507
  // affected test drift検査(witness宣言とfresh affected観測のexact比較)。
508
+ //
509
+ // 比較はTODO単位で、そのTODOが持つ**全affected束縛の観測の和**に対して行う。1面しか持たない
510
+ // TODOでは束縛ごとの比較と同じ結果になり、複数面を所有するTODOでも表現できる——束縛ごとに
511
+ // 宣言全体とexact比較すると、affectedの異なる2 pathを所有するTODOは原理的に成立せず、
512
+ // 実行時に所有が広がった宣言を受け取れなくなる。和なので緩みは入らない(余分な宣言も
513
+ // 足りない宣言もdriftのまま)。
489
514
  const affectedDrift = [];
490
515
  for (const todoId of todoIds) {
491
516
  const witness = request.manual_witness[todoId];
517
+ const declared = [...witness.affected_tests].sort(compareText);
518
+ const queryIds = [];
519
+ const observedUnion = new Set();
520
+ let unreadable = false;
492
521
  for (const binding of bindingsByTodo.get(todoId)) {
493
522
  if (binding.expect.kind !== 'affected') continue;
494
523
  const outcome = outcomeByQueryId.get(binding.query_id);
495
524
  if (resolveBindingStatus(binding, outcome) !== 'ready') continue;
525
+ queryIds.push(binding.query_id);
496
526
  const payload = affectedPayload(outcome.raw, binding.expect.path);
497
- const observed = Array.isArray(payload?.affectedTests)
498
- ? [...payload.affectedTests].sort(compareText)
499
- : null;
500
- const declared = [...witness.affected_tests].sort(compareText);
501
- if (observed === null
502
- || observed.length !== declared.length
503
- || observed.some((test, index) => test !== declared[index])) {
504
- affectedDrift.push({
505
- todo_id: todoId,
506
- query_id: binding.query_id,
507
- declared,
508
- observed,
509
- });
527
+ if (!Array.isArray(payload?.affectedTests)) {
528
+ // 観測が読めないのは「一致しない」とは別の事象である。丸めずそのまま残す。
529
+ affectedDrift.push({ todo_id: todoId, query_id: binding.query_id, declared, observed: null });
530
+ unreadable = true;
531
+ continue;
510
532
  }
533
+ for (const test of payload.affectedTests) observedUnion.add(test);
534
+ }
535
+ if (unreadable || queryIds.length === 0) continue;
536
+ const observed = [...observedUnion].sort(compareText);
537
+ if (observed.length !== declared.length
538
+ || observed.some((test, index) => test !== declared[index])) {
539
+ affectedDrift.push({
540
+ todo_id: todoId,
541
+ query_id: queryIds.sort(compareText).join(','),
542
+ declared,
543
+ observed,
544
+ });
511
545
  }
512
546
  }
513
547
  if (affectedDrift.length > 0) {
@@ -1,4 +1,7 @@
1
1
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
2
+ // 切断可能性の写像はtodo側と同じ正本を使う。計画時と実行時で「切れる種類」の定義が
3
+ // 分かれると、同じ資源が段によって別の答えを持つ。
4
+ import { severabilityOfConflictKind } from './todo-independence-contracts.mjs';
2
5
  import { buildNextRunEvent, buildExecutorPackets } from './runtime-engine.mjs';
3
6
  import { projectRuntimeState } from './runtime-projection.mjs';
4
7
  import {
@@ -594,20 +597,46 @@ export function detectNonConvergentConflicts(options = {}) {
594
597
  ));
595
598
  }
596
599
 
600
+ /**
601
+ * 係争資源が構造的に切れる種類かを返す(ADR 0144の続き、請求項8の入口判定)。
602
+ *
603
+ * pathやsymbolは面を分ければ別々に所有できる。共有stateや外部effectは分けられない——
604
+ * 変換をどれだけ工夫しても同じ資源に触り続ける。**これは「切れる」ではなく「切れる種類だ」で
605
+ * あって、実際に切れるかは試して初めて分かる。**
606
+ */
607
+ export function severabilityOfRuntimeFinding(finding) {
608
+ return severabilityOfConflictKind(typeof finding?.path === 'string' ? 'path' : 'state');
609
+ }
610
+
611
+ /**
612
+ * 競合をどちらの処置へ運ぶか(請求項7=直列化/請求項8=変換)。
613
+ *
614
+ * `seam_transform`を返すのは事前宣言済みtreatmentが係争pathを覆っている時だけである。
615
+ * それ以外の既定は直列化だが、**それは「変換が不可能」という意味ではない**。実行時に初めて
616
+ * 見つかった競合には事前宣言が無いので、既定だけを見ると請求項8へ行く道が無いように見える。
617
+ *
618
+ * そこで`severability`と`transform_attemptable`を併せて返す。装置が言えるのは「切れる種類の
619
+ * 資源か」までで、**実際の難しさは変換を試して五条件で測る**(`run seam resolve`)。試すかどうかは
620
+ * 費用のかかる判断なので装置が決めない——隔離worktreeでの変換、focused test、再indexを毎回
621
+ * 走らせるかは、操作するAIが持つ文脈で決める(AGENTS.md「装置の境界にAIを含める」)。
622
+ */
597
623
  export function routeConflictTreatment(options = {}) {
598
624
  if (!exactRecord(options, ['finding', 'predeclaredTreatments'])) {
599
625
  fail('routeConflictTreatment optionsがexact shapeでない');
600
626
  }
601
627
  const { finding, predeclaredTreatments } = options;
602
628
  if (!plainRecord(finding) || typeof finding.kind !== 'string') fail('findingが不正');
629
+ const severability = severabilityOfRuntimeFinding(finding);
603
630
  if (finding.kind === 'observed_write_conflict' && typeof finding.path === 'string') {
604
631
  for (const treatment of predeclaredTreatments) {
605
632
  if (Array.isArray(treatment.covered_paths) && treatment.covered_paths.includes(finding.path)) {
606
- return { lane: 'seam_transform', treatment: structuredClone(treatment) };
633
+ return { lane: 'seam_transform', treatment: structuredClone(treatment),
634
+ severability, transform_attemptable: true };
607
635
  }
608
636
  }
609
637
  }
610
- return { lane: 'intentional_serial', treatment: null };
638
+ return { lane: 'intentional_serial', treatment: null,
639
+ severability, transform_attemptable: severability === 'code_seam' };
611
640
  }
612
641
 
613
642
  /**