@quolu/lattice 0.31.0 → 0.33.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 (58) hide show
  1. package/README.ja.md +11 -1
  2. package/README.md +11 -2
  3. package/bin/lattice-scripted-worker.mjs +59 -0
  4. package/bin/lattice.mjs +12 -1
  5. package/package.json +1 -1
  6. package/sensor/dist/bin/lattice-sensor.js +72 -4
  7. package/sensor/dist/db/migrations.d.ts +1 -1
  8. package/sensor/dist/db/migrations.d.ts.map +1 -1
  9. package/sensor/dist/db/migrations.js +32 -2
  10. package/sensor/dist/db/migrations.js.map +1 -1
  11. package/sensor/dist/db/queries.d.ts +10 -0
  12. package/sensor/dist/db/queries.d.ts.map +1 -1
  13. package/sensor/dist/db/queries.js +53 -7
  14. package/sensor/dist/db/queries.js.map +1 -1
  15. package/sensor/dist/db/schema.sql +6 -1
  16. package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
  17. package/sensor/dist/extraction/tree-sitter.js +89 -16
  18. package/sensor/dist/extraction/tree-sitter.js.map +1 -1
  19. package/sensor/dist/index.d.ts +8 -1
  20. package/sensor/dist/index.d.ts.map +1 -1
  21. package/sensor/dist/index.js +9 -0
  22. package/sensor/dist/index.js.map +1 -1
  23. package/sensor/dist/resolution/import-resolver.d.ts +11 -0
  24. package/sensor/dist/resolution/import-resolver.d.ts.map +1 -1
  25. package/sensor/dist/resolution/import-resolver.js +13 -5
  26. package/sensor/dist/resolution/import-resolver.js.map +1 -1
  27. package/sensor/dist/resolution/index.d.ts.map +1 -1
  28. package/sensor/dist/resolution/index.js +11 -0
  29. package/sensor/dist/resolution/index.js.map +1 -1
  30. package/sensor/dist/resolution/types.d.ts +4 -0
  31. package/sensor/dist/resolution/types.d.ts.map +1 -1
  32. package/sensor/dist/types.d.ts +16 -0
  33. package/sensor/dist/types.d.ts.map +1 -1
  34. package/src/artifact-contracts.mjs +3 -0
  35. package/src/cli-help.mjs +6 -0
  36. package/src/rc3-scripted-campaign.mjs +3 -1
  37. package/src/runtime-cli.mjs +454 -97
  38. package/src/runtime-contracts.mjs +1 -1
  39. package/src/runtime-control-store.mjs +24 -1
  40. package/src/runtime-controller-protocol.mjs +21 -3
  41. package/src/runtime-decision-verifier.mjs +1 -1
  42. package/src/runtime-diff-observer.mjs +2 -2
  43. package/src/runtime-front-end.mjs +47 -13
  44. package/src/runtime-hold-recompile.mjs +31 -2
  45. package/src/runtime-io-sentinel.mjs +16 -9
  46. package/src/runtime-managed-supervisor.mjs +9 -1
  47. package/src/runtime-multi-epoch-store.mjs +2 -2
  48. package/src/runtime-projection.mjs +27 -0
  49. package/src/runtime-scripted-adapter-controller.mjs +139 -25
  50. package/src/runtime-seam-resolve.mjs +156 -13
  51. package/src/runtime-seam-treatment.mjs +2 -1
  52. package/src/seam-apply.mjs +101 -4
  53. package/src/seam-cost.mjs +322 -0
  54. package/src/seam-gate.mjs +146 -0
  55. package/src/seam-rewrite.mjs +102 -16
  56. package/src/seam-verification.mjs +26 -3
  57. package/src/sensor-adapter.mjs +6 -1
  58. 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,
@@ -754,13 +842,13 @@ async function driveInitialScriptedManagedEpoch({
754
842
  controlEvents,
755
843
  sentinel = null,
756
844
  preDispatchBindings = null,
845
+ drainEscalations = null,
757
846
  }) {
758
847
  let events = [...initialEvents];
759
848
  const { plan, manifests, executor_packets: packets } = committed.bundle;
760
849
  const controllerId = activation.controllerDescriptor.controller_id;
761
850
  const registrationDigest = activation.registration.registration_digest;
762
851
  const sessionNonceDigest = digestArtifact(activation.sessionNonce);
763
- const processGroupId = activation.childPid;
764
852
  for (;;) {
765
853
  const frontier = computeReadyFrontier({ plan, events }).dispatchable;
766
854
  if (frontier.length === 0) break;
@@ -862,12 +950,19 @@ async function driveInitialScriptedManagedEpoch({
862
950
  controller_session_nonce_digest:
863
951
  activation.controllerDescriptor.controller_session_nonce_digest,
864
952
  direct_os_observation_binding: {
865
- process_pid: activation.childPid,
866
- process_group_id: processGroupId,
953
+ // **controllerではなくworker processを指す。** holdは静止の証明を要求し、
954
+ // 直接OS観測はここで名指しされたprocessが実際に停止していることを確かめる。
955
+ // controllerを指していた頃は、止めれば応答できず止めなければ証明できなかった。
956
+ process_pid: response.worker_process.pid,
957
+ process_group_id: response.worker_process.process_group_id,
867
958
  process_start_identity:
868
- structuredClone(activation.controllerDescriptor.process_start_identity),
959
+ structuredClone(response.worker_process.process_start_identity),
960
+ // workerはさらに子を持たない。空配列は「子が居ない」という主張であり、
961
+ // 直接OS観測は実測と突き合わせて未記録のchildが居ないことまで確かめる。
962
+ process_children: [],
869
963
  // TODOごとの木を指す。ここがrepo rootだった頃、帰属はrootから決まらなかった。
870
964
  worktree_path: worktreeByTodo.get(packet.todo_id),
965
+ worktree_realpath: worktreeByTodo.get(packet.todo_id),
871
966
  base_sha: packet.base_sha,
872
967
  },
873
968
  };
@@ -877,37 +972,27 @@ async function driveInitialScriptedManagedEpoch({
877
972
  event.kind === 'executor_dispatched'
878
973
  && event.payload?.executor_handle === executorHandle
879
974
  ));
880
- // workerはdispatchで終わらない。走り続けている間はrunningが返るので、
881
- // 完了まで待つ。ここが待てないと、走行中の観測が成立する構成を持てない。
882
- const deadline = Date.now() + SCRIPTED_OBSERVE_TIMEOUT_MS;
883
- for (;;) {
884
- const response = await managedSupervisor.route('observe', controllerId, {
885
- executor_handle: executorHandle,
886
- expected_epoch: dispatch.plan_epoch,
887
- expected_lease_digest: dispatch.payload.write_lease_digest,
888
- });
889
- if (response.observation.state === 'terminal') {
890
- const receipt = await readScriptedControllerReceipt({
891
- runDir,
892
- controllerId,
893
- payloadDigest: response.observation.payload_digest,
894
- });
895
- return { state: 'terminal', receipt };
896
- }
897
- if (response.observation.state !== 'running') {
898
- throw new ManagedRuntimeError(
899
- 'ADAPTER_CONTROLLER_UNAVAILABLE',
900
- `scripted controllerが未知のstateを返した: ${response.observation.state}`,
901
- );
902
- }
903
- if (Date.now() >= deadline) {
904
- throw new ManagedRuntimeError(
905
- 'ADAPTER_CONTROLLER_UNAVAILABLE',
906
- `scripted workerが時間内に終わらない: ${executorHandle}`,
907
- );
908
- }
909
- await new Promise((resolve) => { setTimeout(resolve, SCRIPTED_OBSERVE_POLL_MS); });
975
+ // **ここでpollしない。** 待つのは駆動側の仕事である——workerが走っている間が
976
+ // 実行時競合を掴める唯一の窓であり、その窓は駆動側がeventsを手元に持っている時
977
+ // にしか安全に触れない。ここで待つと、窓の間ずっと駆動側が止まる。
978
+ const response = await managedSupervisor.route('observe', controllerId, {
979
+ executor_handle: executorHandle,
980
+ expected_epoch: dispatch.plan_epoch,
981
+ expected_lease_digest: dispatch.payload.write_lease_digest,
982
+ });
983
+ if (response.observation.state === 'running') return { state: 'running' };
984
+ if (response.observation.state !== 'terminal') {
985
+ throw new ManagedRuntimeError(
986
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
987
+ `scripted controllerが未知のstateを返した: ${response.observation.state}`,
988
+ );
910
989
  }
990
+ const receipt = await readScriptedControllerReceipt({
991
+ runDir,
992
+ controllerId,
993
+ payloadDigest: response.observation.payload_digest,
994
+ });
995
+ return { state: 'terminal', receipt };
911
996
  },
912
997
  };
913
998
  const dispatched = await dispatchReadyFrontier({
@@ -933,22 +1018,52 @@ async function driveInitialScriptedManagedEpoch({
933
1018
  rootOf: (todoId) => events.findLast((event) => event.kind === 'executor_dispatched'
934
1019
  && event.subject?.kind === 'todo' && event.subject.ref === todoId)
935
1020
  ?.payload?.direct_os_observation_binding?.worktree_path });
936
- for (const todoId of dispatched.dispatched) {
937
- const observed = await observeExecutor({
938
- runId: request.request_id,
939
- todoId,
940
- plan,
941
- events,
942
- adapter: managedAdapter,
943
- recordedAt: canonicalNow(),
944
- });
945
- events = observed.events;
946
- await replaceEventsAtomically(runDir, events);
947
- syncSentinelWatches({ sentinel, runningTodoIds: projectRuntimeState({ events }).running,
948
- rootOf: (todoId) => events.findLast((event) => event.kind === 'executor_dispatched'
949
- && event.subject?.kind === 'todo' && event.subject.ref === todoId)
950
- ?.payload?.direct_os_observation_binding?.worktree_path });
1021
+ // **workerが走っている間だけが、実行時競合を掴める窓である。** 観測を1件ずつ待たずに
1022
+ // 回し、その合間に早期警報のescalationを捌く。捌くのは`replaceEventsAtomically`の
1023
+ // 直後だけ——そこでしかdiskとメモリのeventsが一致していない。
1024
+ const awaiting = new Set(dispatched.dispatched);
1025
+ const observeDeadline = Date.now() + SCRIPTED_OBSERVE_TIMEOUT_MS;
1026
+ let frozen = false;
1027
+ while (awaiting.size > 0) {
1028
+ const drained = await drainEscalations?.(events) ?? null;
1029
+ if (drained !== null) {
1030
+ events = drained;
1031
+ if (projectRuntimeState({ events }).freeze !== null) { frozen = true; break; }
1032
+ }
1033
+ let progressed = false;
1034
+ for (const todoId of [...awaiting]) {
1035
+ const observed = await observeExecutor({
1036
+ runId: request.request_id,
1037
+ todoId,
1038
+ plan,
1039
+ events,
1040
+ adapter: managedAdapter,
1041
+ recordedAt: canonicalNow(),
1042
+ });
1043
+ if (observed.observation.state === 'running') continue;
1044
+ events = observed.events;
1045
+ await replaceEventsAtomically(runDir, events);
1046
+ syncSentinelWatches({ sentinel, runningTodoIds: projectRuntimeState({ events }).running,
1047
+ rootOf: (id) => events.findLast((event) => event.kind === 'executor_dispatched'
1048
+ && event.subject?.kind === 'todo' && event.subject.ref === id)
1049
+ ?.payload?.direct_os_observation_binding?.worktree_path });
1050
+ awaiting.delete(todoId);
1051
+ progressed = true;
1052
+ }
1053
+ if (awaiting.size === 0) break;
1054
+ if (Date.now() >= observeDeadline) {
1055
+ throw new ManagedRuntimeError(
1056
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
1057
+ `scripted workerが時間内に終わらない: ${[...awaiting].join(',')}`,
1058
+ );
1059
+ }
1060
+ if (!progressed) {
1061
+ await new Promise((resolve) => { setTimeout(resolve, SCRIPTED_OBSERVE_POLL_MS); });
1062
+ }
951
1063
  }
1064
+ // holdが掛かったらdispatchを進めない。freeze後のreceiptはstaleとして拒否されるので、
1065
+ // 裁定へ進むと「受理されなかった」で落ちるだけである。
1066
+ if (frozen) return events;
952
1067
  const adjudicated = adjudicatePendingReceipts({
953
1068
  runId: request.request_id,
954
1069
  plan,
@@ -1103,6 +1218,64 @@ async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, st
1103
1218
  return resolution.lane === 'seam_transform' ? 0 : 1;
1104
1219
  }
1105
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
+
1106
1279
  async function runObserve({ runDir, stdout }) {
1107
1280
  const { events } = await readRunStore(runDir);
1108
1281
  const chain = verifyRunEventChain({ events });
@@ -1152,7 +1325,9 @@ async function runStatus({ runDir, stdout }) {
1152
1325
  if (managed !== null) {
1153
1326
  output.schema = 'lattice.managed_run_status.v1';
1154
1327
  const runtimeProjection = {
1155
- schema: 'lattice.runtime_status_projection.v1',
1328
+ // v2は`treatment_advice`の追加。既定modeが直列化でも変換を試せる場合があることを、
1329
+ // 運転側が見える形にした(ct-003)。
1330
+ schema: 'lattice.runtime_status_projection.v2',
1156
1331
  ...projectRuntimeStatusOverlays({ events }),
1157
1332
  runtime_frozen: managedFrozen,
1158
1333
  };
@@ -1810,6 +1985,13 @@ export async function runManagedSupervisorDaemon({
1810
1985
  * 「両方が書いた」と読めてしまう。これをholdへ繋ぐと、無実のTODOを止める。
1811
1986
  * 帰属が立たないならescalationへ進めない。警報と実在の記録はそのまま残す。
1812
1987
  */
1988
+ /**
1989
+ * holdが要求する静止を、この構成で証明できるか。
1990
+ *
1991
+ * 直接OS観測はexecutorのprocessが停止していることを実測する。scripted controllerは
1992
+ * 自分のprocessで作業するので、そのprocessを止めると制御そのものが止まる——止めない限り
1993
+ * 証明できず、止めれば応答できない。**別processのexecutorを持つまでこの穴は埋まらない。**
1994
+ */
1813
1995
  const attributionIsDistinct = (warning, roots) => {
1814
1996
  const paths = warning.todo_ids.map((todoId) => roots[todoId]);
1815
1997
  if (paths.some((value) => typeof value !== 'string')) return false;
@@ -1853,17 +2035,7 @@ export async function runManagedSupervisorDaemon({
1853
2035
  *
1854
2036
  * @returns {{ok: true, expected_epoch: number}|{ok: false, outcome: string, detail: string}}
1855
2037
  */
1856
- const durablyRecordProbeCheckpoints = async (escalation, checkpointsByTodo) => {
1857
- let lock;
1858
- try {
1859
- lock = await acquireRuntimeLifecycleLock({ runDir,
1860
- sessionNonceDigest: digestArtifact(sessionNonce), operation: 'finding_record',
1861
- requestId: `${escalation.escalation_id}.checkpoint`,
1862
- timeoutMs: IO_ESCALATION_LOCK_TIMEOUT_MS, retryIntervalMs: 25 });
1863
- } catch (error) {
1864
- return { ok: false, outcome: 'rejected',
1865
- detail: `lifecycle lockを取れない: ${error?.code ?? 'RUN_BUSY'}` };
1866
- }
2038
+ const recordProbeCheckpointsUnlocked = async (escalation, checkpointsByTodo) => {
1867
2039
  try {
1868
2040
  const active = await readCommittedEpochStore(runDir);
1869
2041
  if (active === null) return { ok: false, outcome: 'skipped', detail: 'managed epochが未commit' };
@@ -1894,6 +2066,23 @@ export async function runManagedSupervisorDaemon({
1894
2066
  } catch (error) {
1895
2067
  return { ok: false, outcome: 'rejected',
1896
2068
  detail: `probe checkpointを耐久化できない: ${error?.code ?? String(error?.message ?? error)}` };
2069
+ }
2070
+ };
2071
+
2072
+ /** lifecycle lockを自分で取ってから耐久化する版(epoch駆動の外から呼ぶ時)。 */
2073
+ const durablyRecordProbeCheckpoints = async (escalation, checkpointsByTodo) => {
2074
+ let lock;
2075
+ try {
2076
+ lock = await acquireRuntimeLifecycleLock({ runDir,
2077
+ sessionNonceDigest: digestArtifact(sessionNonce), operation: 'finding_record',
2078
+ requestId: `${escalation.escalation_id}.checkpoint`,
2079
+ timeoutMs: IO_ESCALATION_LOCK_TIMEOUT_MS, retryIntervalMs: 25 });
2080
+ } catch (error) {
2081
+ return { ok: false, outcome: 'rejected',
2082
+ detail: `lifecycle lockを取れない: ${error?.code ?? 'RUN_BUSY'}` };
2083
+ }
2084
+ try {
2085
+ return await recordProbeCheckpointsUnlocked(escalation, checkpointsByTodo);
1897
2086
  } finally {
1898
2087
  await lock.release();
1899
2088
  }
@@ -1909,7 +2098,10 @@ export async function runManagedSupervisorDaemon({
1909
2098
  *
1910
2099
  * 途中で断られたらそこで止め、理由をjournalへ残す。静かに別経路へ逃げない。
1911
2100
  */
1912
- const escalateIoWarning = async (warning, probed, packets) => {
2101
+ const escalateIoWarning = async (warning, probed, packets, options = {}) => {
2102
+ // 耐久化と送信の経路だけを差し替える。判断そのものはどちらの文脈でも同一にする——
2103
+ // 分けると「epoch駆動の中と外で違う止め方をする」ことになり、記録が読めなくなる。
2104
+ const { recordCheckpoints = durablyRecordProbeCheckpoints, dispatchControl = null } = options;
1913
2105
  const built = buildIoEscalation({ warning, probe: probed,
1914
2106
  checkpointsByTodo: probed.checkpoints, packets });
1915
2107
  if (built === null) return null;
@@ -1928,7 +2120,7 @@ export async function runManagedSupervisorDaemon({
1928
2120
  return decide('skipped',
1929
2121
  'worktree rootを共有する構成では書き手を特定できない(帰属はrootだけで決まる)');
1930
2122
  }
1931
- const recorded = await durablyRecordProbeCheckpoints(escalation, probed.checkpoints);
2123
+ const recorded = await recordCheckpoints(escalation, probed.checkpoints);
1932
2124
  if (!recorded.ok) return decide(recorded.outcome, recorded.detail);
1933
2125
 
1934
2126
  const submit = async (operation, { artifact = null, artifactDigest = null,
@@ -1940,13 +2132,15 @@ export async function runManagedSupervisorDaemon({
1940
2132
  artifact, artifactDigest, checkpointDigest,
1941
2133
  expectedEpoch: recorded.expected_epoch, expectedQueueDigest: null }),
1942
2134
  });
1943
- return handler(controlRequest);
2135
+ return dispatchControl === null ? handler(controlRequest) : dispatchControl(controlRequest);
1944
2136
  };
1945
2137
  const unmetOf = (response) => (response?.result?.unmet ?? []).join('/')
1946
2138
  || String(response?.outcome ?? 'unknown');
1947
2139
 
1948
2140
  const findingResponse = await submit('finding_record', {
1949
- artifact: escalation.candidate, artifactDigest: escalation.candidate.candidate_digest,
2141
+ // control operationが要求するのはartifact全体のdigestである。`candidate_digest`は
2142
+ // 自分の欄を除いた自己digestなので、そのまま渡すとbindingが合わず必ず弾かれる。
2143
+ artifact: escalation.candidate, artifactDigest: digestArtifact(escalation.candidate),
1950
2144
  checkpointDigest: escalation.checkpoint_digest,
1951
2145
  }).catch((error) => ({ outcome: 'rejected',
1952
2146
  result: { unmet: [String(error?.code ?? error?.message ?? error)] } }));
@@ -1970,6 +2164,17 @@ export async function runManagedSupervisorDaemon({
1970
2164
  return decide('held', `早期警報からhold: ${warning.kind}/${warning.path}`, findingDigest);
1971
2165
  };
1972
2166
 
2167
+ /**
2168
+ * epoch駆動中に届いた、実在確認済みの警報。
2169
+ *
2170
+ * **駆動中はここへ積むだけにする。** epoch駆動はrun eventsをメモリに抱えたままawaitを
2171
+ * またぎ、節目ごとに全体を置換する。その最中に横から追記すると、次の置換で消える——
2172
+ * 静かに記録を失う方向に壊れる。捌くのは駆動側の安全点(disk とメモリが一致している点)
2173
+ * であり、そこはworkerがまだ走っている最中なのでholdが成立する。
2174
+ */
2175
+ const pendingEscalations = [];
2176
+ let epochDriveActive = false;
2177
+
1973
2178
  /**
1974
2179
  * 警報1件の全行程(probe → 記録 → escalation)。
1975
2180
  *
@@ -1980,6 +2185,11 @@ export async function runManagedSupervisorDaemon({
1980
2185
  const handleIoWarning = async (warning, packets) => {
1981
2186
  const probed = await probeWarning(warning).catch(() => (
1982
2187
  { outcome: 'unprobed', writers: [], checkpoints: {} }));
2188
+ if (probed.outcome === 'observed' && epochDriveActive) {
2189
+ pendingEscalations.push({ warning, probed, packets });
2190
+ await recordIoWarning(warning, 'observed');
2191
+ return;
2192
+ }
1983
2193
  const decided = probed.outcome === 'observed'
1984
2194
  ? await escalateIoWarning(warning, probed, packets).catch(() => null)
1985
2195
  : null;
@@ -1987,17 +2197,51 @@ export async function runManagedSupervisorDaemon({
1987
2197
  decided === null || decided.outcome === 'skipped' ? probed.outcome : 'escalated');
1988
2198
  };
1989
2199
 
2200
+ /**
2201
+ * epoch駆動の安全点でescalationを捌く(ADR 0143)。
2202
+ *
2203
+ * 呼ぶのは`replaceEventsAtomically`の直後だけとする。そこではdiskとメモリのeventsが
2204
+ * 一致しているので、control operationがdiskを読み書きしても駆動側の像とずれない。
2205
+ * 捌いた後はdiskを読み直して返す——`conflict`と`hold`はevents.jsonを書き換えるため。
2206
+ *
2207
+ * lifecycle lockは取らない。既に駆動側(activate)が握っている内側であり、ここで
2208
+ * 取り直すと自分自身を待つ。同じ理由で`handler`ではなく`executeControl`を直接使う。
2209
+ *
2210
+ * @returns {Promise<Array|null>} 捌いた結果のevents。何も捌かなければnull。
2211
+ */
2212
+ const drainPendingEscalations = async (events) => {
2213
+ if (pendingEscalations.length === 0) return null;
2214
+ let current = events;
2215
+ while (pendingEscalations.length > 0) {
2216
+ const { warning, probed, packets } = pendingEscalations.shift();
2217
+ const decided = await escalateIoWarning(warning, probed, packets, {
2218
+ recordCheckpoints: recordProbeCheckpointsUnlocked,
2219
+ dispatchControl: (controlRequest) => executeControl(controlRequest),
2220
+ }).catch((error) => ({ outcome: 'rejected',
2221
+ detail: String(error?.message ?? error), finding_digest: null }));
2222
+ if (decided === null) continue;
2223
+ current = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
2224
+ // holdが掛かったら、残りの警報は同じfreezeの下にある。二重にholdを試みない。
2225
+ if (projectRuntimeState({ events: current }).freeze !== null) break;
2226
+ }
2227
+ return current;
2228
+ };
2229
+
1990
2230
  /** 警報の処理を直列化する鎖。`onWarning`はここへ繋ぐだけにする。 */
1991
2231
  let escalationChain = Promise.resolve();
1992
2232
 
1993
- 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;
1994
2238
  const events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
1995
2239
  const dispatch = events.findLast((event) => event.kind === 'executor_dispatched'
1996
- && event.subject?.kind === 'todo' && event.subject.ref === binding?.todo_id
1997
- && event.payload?.executor_handle === binding?.executor_handle);
2240
+ && event.subject?.kind === 'todo' && event.subject.ref === todoId
2241
+ && event.payload?.executor_handle === executorHandle);
1998
2242
  const observation = dispatch?.payload?.direct_os_observation_binding;
1999
2243
  if (observation === null || typeof observation !== 'object' || Array.isArray(observation)) {
2000
- 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'}`);
2001
2245
  }
2002
2246
  return structuredClone(observation);
2003
2247
  };
@@ -2115,18 +2359,24 @@ export async function runManagedSupervisorDaemon({
2115
2359
  return settled;
2116
2360
  },
2117
2361
  });
2118
- await driveInitialScriptedManagedEpoch({
2119
- runDir,
2120
- repoRoot,
2121
- request,
2122
- committed,
2123
- activation,
2124
- managedSupervisor,
2125
- sentinel,
2126
- initialEvents: events,
2127
- controlEvents: () => controlEvents,
2128
- preDispatchBindings,
2129
- });
2362
+ epochDriveActive = true;
2363
+ try {
2364
+ await driveInitialScriptedManagedEpoch({
2365
+ runDir,
2366
+ repoRoot,
2367
+ request,
2368
+ committed,
2369
+ activation,
2370
+ managedSupervisor,
2371
+ sentinel,
2372
+ initialEvents: events,
2373
+ controlEvents: () => controlEvents,
2374
+ preDispatchBindings,
2375
+ drainEscalations: drainPendingEscalations,
2376
+ });
2377
+ } finally {
2378
+ epochDriveActive = false;
2379
+ }
2130
2380
  }
2131
2381
  if (restarting) {
2132
2382
  await managedSupervisor.recoveryBarrier({ barrierId: `recovery-${randomUUID()}`,
@@ -2316,6 +2566,19 @@ export async function runManagedSupervisorDaemon({
2316
2566
  if (finding.plan_epoch !== active.pointer.plan_epoch) {
2317
2567
  throw new ManagedRuntimeError('STALE_FINDING', 'findingはactive epochに属さない');
2318
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
+ }
2319
2582
  let events = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
2320
2583
  const todoId = finding.finding?.todo_ids?.[0] ?? active.bundle.plan.nodes[0].todo_id;
2321
2584
  events.push(buildNextRunEvent({ events, runId: request.request_id, kind: 'conflict_found', planEpoch: active.pointer.plan_epoch,
@@ -2534,20 +2797,40 @@ export async function runManagedSupervisorDaemon({
2534
2797
  !== canonicalizeArtifact(treatment.todo_ids ?? treatment.predecessor_task_ids)) {
2535
2798
  throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'treatmentがhold finding/todo集合と一致しない');
2536
2799
  }
2537
- if (recompileRequest.mode === 'intentional_serial'
2538
- && treatmentFinding.finding.resource_id !== treatment.resource_id) {
2539
- throw new ManagedRuntimeError('INVALID_RECOMPILE_REQUEST', 'serial resourceがfinding resourceと一致しない');
2540
- }
2541
- if (recompileRequest.mode === 'intentional_serial'
2542
- && !treatment.todo_ids.every((todoId) => {
2543
- const manifest = active.bundle.manifests[todoId];
2544
- return manifest?.resources?.includes(treatment.resource_id)
2545
- || manifest?.state_effects?.some((effect) => effect.resource_id === treatment.resource_id);
2546
- })) {
2547
- 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
+ }
2548
2824
  }
2549
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>`にする。
2550
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' })),
2551
2834
  ...manifest.resources.map((resourceId) => ({ resource_id: resourceId,
2552
2835
  owner_todo_id: todoId, access_kind: 'own' })),
2553
2836
  ...manifest.state_effects.map((effect) => ({ resource_id: effect.resource_id,
@@ -2561,9 +2844,41 @@ export async function runManagedSupervisorDaemon({
2561
2844
  ].sort((left, right) => canonicalizeArtifact(left).localeCompare(canonicalizeArtifact(right)));
2562
2845
  const difference = (left, right) => left.filter((entry) => !right.some((other) =>
2563
2846
  canonicalizeArtifact(entry) === canonicalizeArtifact(other)));
2564
- 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);
2565
2880
  const afterOwnership = ownership(compiled.manifests);
2566
- const beforeEdges = edges(active.bundle.plan);
2881
+ const beforeEdges = sortEntries(reconciledEdges);
2567
2882
  const afterEdges = edges(newPlan);
2568
2883
  const derivedOwnership = { added: difference(afterOwnership, beforeOwnership),
2569
2884
  removed: difference(beforeOwnership, afterOwnership) };
@@ -2712,6 +3027,20 @@ export async function runManagedSupervisorDaemon({
2712
3027
  rebindEvidence.set(todoId, { ...evidence,
2713
3028
  controller_registration_digest: owner.registration.registration_digest });
2714
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
+ }
2715
3044
  for (const todoId of core.planDiff.redispatched) {
2716
3045
  const successors = recompileRequest.task_migration.entries
2717
3046
  .find((entry) => entry.predecessor_task_id === todoId)?.successor_task_ids ?? [];
@@ -2889,9 +3218,22 @@ export async function runManagedSupervisorDaemon({
2889
3218
  recordedAt: canonicalNow() })];
2890
3219
  }
2891
3220
  await replaceEventsAtomically(runDir, events);
2892
- // abandonは成果を捨てる決定なので、workerの木も畳む。closeでは畳まない——
2893
- // 木そのものがrunの成果であり、着地させる前に消したら受理した内容が残らない。
3221
+ // **abandonは成果を捨てる決定である。** worker processの終了はcleanupではなく
3222
+ // その決定の一部なので、誰を止めたかを記録へ残す。closeでは何もしない——
3223
+ // 完走したrunのworkerは既にterminalであり、生きていればそれは欠陥である
3224
+ // (`closeRunIfComplete`が全TODO accepted を要求するので、そもそもcloseへ来ない)。
2894
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
+ // 消したら受理した内容が残らない。
2895
3237
  await removeScriptedWorktrees({ repoRoot, runDir }).catch(() => null);
2896
3238
  }
2897
3239
  await appendControl({ run_id: request.request_id, kind: 'supervisor_stopped',
@@ -3415,6 +3757,11 @@ export async function runManagedSupervisorDaemon({
3415
3757
  // 監視fdを残さない。取り残すとtest fixtureの後片付けが重くなる。
3416
3758
  sentinel?.close();
3417
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);
3418
3765
  if (activationCommitted && activation !== null) {
3419
3766
  await appendControl({ run_id: request.request_id, kind: 'supervisor_stopped',
3420
3767
  session_nonce_digest: digestArtifact(sessionNonce), payload: { signal } });
@@ -3580,6 +3927,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
3580
3927
  requestId: requestIdOverride,
3581
3928
  });
3582
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
+ };
3583
3940
  } else if (argv.length === 9
3584
3941
  && argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
3585
3942
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0