@quolu/lattice 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripted executorのworker process(ADR 0143 Decision 9)。
4
+ *
5
+ * **別processであること自体が要件である。** holdは静止の証明を要求し、直接OS観測は
6
+ * executorのprocessが実際に停止していることまで確かめる。controller自身のprocessで
7
+ * 作業していると、止めれば応答できず、止めなければ証明できない。
8
+ *
9
+ * 書いたあとも生きたまま待つ。作業を終えて消えてしまうと、barrierが掛かった時に
10
+ * 止めるべきprocessが存在せず、静止を証明する相手が居なくなる。
11
+ */
12
+
13
+ import { readFile } from 'node:fs/promises';
14
+
15
+ import { executePacket } from '../src/runtime-scripted-adapter-controller.mjs';
16
+
17
+ const MAX_JOB_BYTES = 8_388_608;
18
+
19
+ function fail(reason) {
20
+ process.stderr.write(`${JSON.stringify({
21
+ schema: 'lattice.scripted_worker_error.v1', reason,
22
+ })}\n`);
23
+ process.exit(1);
24
+ }
25
+
26
+ const jobPath = process.argv[2];
27
+ if (typeof jobPath !== 'string' || jobPath.length === 0) fail('job pathが渡されていない');
28
+
29
+ let job;
30
+ try {
31
+ const bytes = await readFile(jobPath);
32
+ if (bytes.length > MAX_JOB_BYTES) fail('jobが上限byteを超える');
33
+ job = JSON.parse(bytes.toString('utf8'));
34
+ } catch (error) {
35
+ fail(`jobを読めない: ${String(error?.message ?? error)}`);
36
+ }
37
+ if (job?.schema !== 'lattice.scripted_worker_job.v1') fail('job schemaが不正');
38
+
39
+ let result;
40
+ try {
41
+ result = await executePacket({
42
+ packet: job.packet,
43
+ repoRoot: job.worktree_path,
44
+ extraWrites: job.extra_writes ?? [],
45
+ });
46
+ } catch (error) {
47
+ fail(`実行に失敗した: ${String(error?.detail?.reason ?? error?.message ?? error)}`);
48
+ }
49
+
50
+ // 完了はstdoutの1行で報せる。controllerはこれを受けてreceiptを組む。
51
+ process.stdout.write(`${JSON.stringify({
52
+ schema: 'lattice.scripted_worker_result.v1',
53
+ observed_diff: result.observedDiff,
54
+ checkpoint_digest: result.checkpointDigest,
55
+ })}\n`);
56
+
57
+ // 生きたまま待つ。ここがrunの「作業中」であり、barrierはこのprocessを止めて静止を証明する。
58
+ const holdMs = Number.isSafeInteger(job.hold_ms) && job.hold_ms > 0 ? job.hold_ms : 0;
59
+ if (holdMs > 0) await new Promise((resolve) => { setTimeout(resolve, holdMs); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -754,13 +754,13 @@ async function driveInitialScriptedManagedEpoch({
754
754
  controlEvents,
755
755
  sentinel = null,
756
756
  preDispatchBindings = null,
757
+ drainEscalations = null,
757
758
  }) {
758
759
  let events = [...initialEvents];
759
760
  const { plan, manifests, executor_packets: packets } = committed.bundle;
760
761
  const controllerId = activation.controllerDescriptor.controller_id;
761
762
  const registrationDigest = activation.registration.registration_digest;
762
763
  const sessionNonceDigest = digestArtifact(activation.sessionNonce);
763
- const processGroupId = activation.childPid;
764
764
  for (;;) {
765
765
  const frontier = computeReadyFrontier({ plan, events }).dispatchable;
766
766
  if (frontier.length === 0) break;
@@ -862,12 +862,19 @@ async function driveInitialScriptedManagedEpoch({
862
862
  controller_session_nonce_digest:
863
863
  activation.controllerDescriptor.controller_session_nonce_digest,
864
864
  direct_os_observation_binding: {
865
- process_pid: activation.childPid,
866
- process_group_id: processGroupId,
865
+ // **controllerではなくworker processを指す。** holdは静止の証明を要求し、
866
+ // 直接OS観測はここで名指しされたprocessが実際に停止していることを確かめる。
867
+ // controllerを指していた頃は、止めれば応答できず止めなければ証明できなかった。
868
+ process_pid: response.worker_process.pid,
869
+ process_group_id: response.worker_process.process_group_id,
867
870
  process_start_identity:
868
- structuredClone(activation.controllerDescriptor.process_start_identity),
871
+ structuredClone(response.worker_process.process_start_identity),
872
+ // workerはさらに子を持たない。空配列は「子が居ない」という主張であり、
873
+ // 直接OS観測は実測と突き合わせて未記録のchildが居ないことまで確かめる。
874
+ process_children: [],
869
875
  // TODOごとの木を指す。ここがrepo rootだった頃、帰属はrootから決まらなかった。
870
876
  worktree_path: worktreeByTodo.get(packet.todo_id),
877
+ worktree_realpath: worktreeByTodo.get(packet.todo_id),
871
878
  base_sha: packet.base_sha,
872
879
  },
873
880
  };
@@ -877,37 +884,27 @@ async function driveInitialScriptedManagedEpoch({
877
884
  event.kind === 'executor_dispatched'
878
885
  && event.payload?.executor_handle === executorHandle
879
886
  ));
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); });
887
+ // **ここでpollしない。** 待つのは駆動側の仕事である——workerが走っている間が
888
+ // 実行時競合を掴める唯一の窓であり、その窓は駆動側がeventsを手元に持っている時
889
+ // にしか安全に触れない。ここで待つと、窓の間ずっと駆動側が止まる。
890
+ const response = await managedSupervisor.route('observe', controllerId, {
891
+ executor_handle: executorHandle,
892
+ expected_epoch: dispatch.plan_epoch,
893
+ expected_lease_digest: dispatch.payload.write_lease_digest,
894
+ });
895
+ if (response.observation.state === 'running') return { state: 'running' };
896
+ if (response.observation.state !== 'terminal') {
897
+ throw new ManagedRuntimeError(
898
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
899
+ `scripted controllerが未知のstateを返した: ${response.observation.state}`,
900
+ );
910
901
  }
902
+ const receipt = await readScriptedControllerReceipt({
903
+ runDir,
904
+ controllerId,
905
+ payloadDigest: response.observation.payload_digest,
906
+ });
907
+ return { state: 'terminal', receipt };
911
908
  },
912
909
  };
913
910
  const dispatched = await dispatchReadyFrontier({
@@ -933,22 +930,52 @@ async function driveInitialScriptedManagedEpoch({
933
930
  rootOf: (todoId) => events.findLast((event) => event.kind === 'executor_dispatched'
934
931
  && event.subject?.kind === 'todo' && event.subject.ref === todoId)
935
932
  ?.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 });
933
+ // **workerが走っている間だけが、実行時競合を掴める窓である。** 観測を1件ずつ待たずに
934
+ // 回し、その合間に早期警報のescalationを捌く。捌くのは`replaceEventsAtomically`の
935
+ // 直後だけ——そこでしかdiskとメモリのeventsが一致していない。
936
+ const awaiting = new Set(dispatched.dispatched);
937
+ const observeDeadline = Date.now() + SCRIPTED_OBSERVE_TIMEOUT_MS;
938
+ let frozen = false;
939
+ while (awaiting.size > 0) {
940
+ const drained = await drainEscalations?.(events) ?? null;
941
+ if (drained !== null) {
942
+ events = drained;
943
+ if (projectRuntimeState({ events }).freeze !== null) { frozen = true; break; }
944
+ }
945
+ let progressed = false;
946
+ for (const todoId of [...awaiting]) {
947
+ const observed = await observeExecutor({
948
+ runId: request.request_id,
949
+ todoId,
950
+ plan,
951
+ events,
952
+ adapter: managedAdapter,
953
+ recordedAt: canonicalNow(),
954
+ });
955
+ if (observed.observation.state === 'running') continue;
956
+ events = observed.events;
957
+ await replaceEventsAtomically(runDir, events);
958
+ syncSentinelWatches({ sentinel, runningTodoIds: projectRuntimeState({ events }).running,
959
+ rootOf: (id) => events.findLast((event) => event.kind === 'executor_dispatched'
960
+ && event.subject?.kind === 'todo' && event.subject.ref === id)
961
+ ?.payload?.direct_os_observation_binding?.worktree_path });
962
+ awaiting.delete(todoId);
963
+ progressed = true;
964
+ }
965
+ if (awaiting.size === 0) break;
966
+ if (Date.now() >= observeDeadline) {
967
+ throw new ManagedRuntimeError(
968
+ 'ADAPTER_CONTROLLER_UNAVAILABLE',
969
+ `scripted workerが時間内に終わらない: ${[...awaiting].join(',')}`,
970
+ );
971
+ }
972
+ if (!progressed) {
973
+ await new Promise((resolve) => { setTimeout(resolve, SCRIPTED_OBSERVE_POLL_MS); });
974
+ }
951
975
  }
976
+ // holdが掛かったらdispatchを進めない。freeze後のreceiptはstaleとして拒否されるので、
977
+ // 裁定へ進むと「受理されなかった」で落ちるだけである。
978
+ if (frozen) return events;
952
979
  const adjudicated = adjudicatePendingReceipts({
953
980
  runId: request.request_id,
954
981
  plan,
@@ -1810,6 +1837,15 @@ export async function runManagedSupervisorDaemon({
1810
1837
  * 「両方が書いた」と読めてしまう。これをholdへ繋ぐと、無実のTODOを止める。
1811
1838
  * 帰属が立たないならescalationへ進めない。警報と実在の記録はそのまま残す。
1812
1839
  */
1840
+ /**
1841
+ * holdが要求する静止を、この構成で証明できるか。
1842
+ *
1843
+ * 直接OS観測はexecutorのprocessが停止していることを実測する。scripted controllerは
1844
+ * 自分のprocessで作業するので、そのprocessを止めると制御そのものが止まる——止めない限り
1845
+ * 証明できず、止めれば応答できない。**別processのexecutorを持つまでこの穴は埋まらない。**
1846
+ */
1847
+ const canProveQuiescence = () => true;
1848
+
1813
1849
  const attributionIsDistinct = (warning, roots) => {
1814
1850
  const paths = warning.todo_ids.map((todoId) => roots[todoId]);
1815
1851
  if (paths.some((value) => typeof value !== 'string')) return false;
@@ -1853,17 +1889,7 @@ export async function runManagedSupervisorDaemon({
1853
1889
  *
1854
1890
  * @returns {{ok: true, expected_epoch: number}|{ok: false, outcome: string, detail: string}}
1855
1891
  */
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
- }
1892
+ const recordProbeCheckpointsUnlocked = async (escalation, checkpointsByTodo) => {
1867
1893
  try {
1868
1894
  const active = await readCommittedEpochStore(runDir);
1869
1895
  if (active === null) return { ok: false, outcome: 'skipped', detail: 'managed epochが未commit' };
@@ -1894,6 +1920,23 @@ export async function runManagedSupervisorDaemon({
1894
1920
  } catch (error) {
1895
1921
  return { ok: false, outcome: 'rejected',
1896
1922
  detail: `probe checkpointを耐久化できない: ${error?.code ?? String(error?.message ?? error)}` };
1923
+ }
1924
+ };
1925
+
1926
+ /** lifecycle lockを自分で取ってから耐久化する版(epoch駆動の外から呼ぶ時)。 */
1927
+ const durablyRecordProbeCheckpoints = async (escalation, checkpointsByTodo) => {
1928
+ let lock;
1929
+ try {
1930
+ lock = await acquireRuntimeLifecycleLock({ runDir,
1931
+ sessionNonceDigest: digestArtifact(sessionNonce), operation: 'finding_record',
1932
+ requestId: `${escalation.escalation_id}.checkpoint`,
1933
+ timeoutMs: IO_ESCALATION_LOCK_TIMEOUT_MS, retryIntervalMs: 25 });
1934
+ } catch (error) {
1935
+ return { ok: false, outcome: 'rejected',
1936
+ detail: `lifecycle lockを取れない: ${error?.code ?? 'RUN_BUSY'}` };
1937
+ }
1938
+ try {
1939
+ return await recordProbeCheckpointsUnlocked(escalation, checkpointsByTodo);
1897
1940
  } finally {
1898
1941
  await lock.release();
1899
1942
  }
@@ -1909,7 +1952,10 @@ export async function runManagedSupervisorDaemon({
1909
1952
  *
1910
1953
  * 途中で断られたらそこで止め、理由をjournalへ残す。静かに別経路へ逃げない。
1911
1954
  */
1912
- const escalateIoWarning = async (warning, probed, packets) => {
1955
+ const escalateIoWarning = async (warning, probed, packets, options = {}) => {
1956
+ // 耐久化と送信の経路だけを差し替える。判断そのものはどちらの文脈でも同一にする——
1957
+ // 分けると「epoch駆動の中と外で違う止め方をする」ことになり、記録が読めなくなる。
1958
+ const { recordCheckpoints = durablyRecordProbeCheckpoints, dispatchControl = null } = options;
1913
1959
  const built = buildIoEscalation({ warning, probe: probed,
1914
1960
  checkpointsByTodo: probed.checkpoints, packets });
1915
1961
  if (built === null) return null;
@@ -1928,7 +1974,20 @@ export async function runManagedSupervisorDaemon({
1928
1974
  return decide('skipped',
1929
1975
  'worktree rootを共有する構成では書き手を特定できない(帰属はrootだけで決まる)');
1930
1976
  }
1931
- const recorded = await durablyRecordProbeCheckpoints(escalation, probed.checkpoints);
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
+ const recorded = await recordCheckpoints(escalation, probed.checkpoints);
1932
1991
  if (!recorded.ok) return decide(recorded.outcome, recorded.detail);
1933
1992
 
1934
1993
  const submit = async (operation, { artifact = null, artifactDigest = null,
@@ -1940,13 +1999,15 @@ export async function runManagedSupervisorDaemon({
1940
1999
  artifact, artifactDigest, checkpointDigest,
1941
2000
  expectedEpoch: recorded.expected_epoch, expectedQueueDigest: null }),
1942
2001
  });
1943
- return handler(controlRequest);
2002
+ return dispatchControl === null ? handler(controlRequest) : dispatchControl(controlRequest);
1944
2003
  };
1945
2004
  const unmetOf = (response) => (response?.result?.unmet ?? []).join('/')
1946
2005
  || String(response?.outcome ?? 'unknown');
1947
2006
 
1948
2007
  const findingResponse = await submit('finding_record', {
1949
- artifact: escalation.candidate, artifactDigest: escalation.candidate.candidate_digest,
2008
+ // control operationが要求するのはartifact全体のdigestである。`candidate_digest`は
2009
+ // 自分の欄を除いた自己digestなので、そのまま渡すとbindingが合わず必ず弾かれる。
2010
+ artifact: escalation.candidate, artifactDigest: digestArtifact(escalation.candidate),
1950
2011
  checkpointDigest: escalation.checkpoint_digest,
1951
2012
  }).catch((error) => ({ outcome: 'rejected',
1952
2013
  result: { unmet: [String(error?.code ?? error?.message ?? error)] } }));
@@ -1970,6 +2031,17 @@ export async function runManagedSupervisorDaemon({
1970
2031
  return decide('held', `早期警報からhold: ${warning.kind}/${warning.path}`, findingDigest);
1971
2032
  };
1972
2033
 
2034
+ /**
2035
+ * epoch駆動中に届いた、実在確認済みの警報。
2036
+ *
2037
+ * **駆動中はここへ積むだけにする。** epoch駆動はrun eventsをメモリに抱えたままawaitを
2038
+ * またぎ、節目ごとに全体を置換する。その最中に横から追記すると、次の置換で消える——
2039
+ * 静かに記録を失う方向に壊れる。捌くのは駆動側の安全点(disk とメモリが一致している点)
2040
+ * であり、そこはworkerがまだ走っている最中なのでholdが成立する。
2041
+ */
2042
+ const pendingEscalations = [];
2043
+ let epochDriveActive = false;
2044
+
1973
2045
  /**
1974
2046
  * 警報1件の全行程(probe → 記録 → escalation)。
1975
2047
  *
@@ -1980,6 +2052,11 @@ export async function runManagedSupervisorDaemon({
1980
2052
  const handleIoWarning = async (warning, packets) => {
1981
2053
  const probed = await probeWarning(warning).catch(() => (
1982
2054
  { outcome: 'unprobed', writers: [], checkpoints: {} }));
2055
+ if (probed.outcome === 'observed' && epochDriveActive) {
2056
+ pendingEscalations.push({ warning, probed, packets });
2057
+ await recordIoWarning(warning, 'observed');
2058
+ return;
2059
+ }
1983
2060
  const decided = probed.outcome === 'observed'
1984
2061
  ? await escalateIoWarning(warning, probed, packets).catch(() => null)
1985
2062
  : null;
@@ -1987,6 +2064,36 @@ export async function runManagedSupervisorDaemon({
1987
2064
  decided === null || decided.outcome === 'skipped' ? probed.outcome : 'escalated');
1988
2065
  };
1989
2066
 
2067
+ /**
2068
+ * epoch駆動の安全点でescalationを捌く(ADR 0143)。
2069
+ *
2070
+ * 呼ぶのは`replaceEventsAtomically`の直後だけとする。そこではdiskとメモリのeventsが
2071
+ * 一致しているので、control operationがdiskを読み書きしても駆動側の像とずれない。
2072
+ * 捌いた後はdiskを読み直して返す——`conflict`と`hold`はevents.jsonを書き換えるため。
2073
+ *
2074
+ * lifecycle lockは取らない。既に駆動側(activate)が握っている内側であり、ここで
2075
+ * 取り直すと自分自身を待つ。同じ理由で`handler`ではなく`executeControl`を直接使う。
2076
+ *
2077
+ * @returns {Promise<Array|null>} 捌いた結果のevents。何も捌かなければnull。
2078
+ */
2079
+ const drainPendingEscalations = async (events) => {
2080
+ if (pendingEscalations.length === 0) return null;
2081
+ let current = events;
2082
+ while (pendingEscalations.length > 0) {
2083
+ const { warning, probed, packets } = pendingEscalations.shift();
2084
+ const decided = await escalateIoWarning(warning, probed, packets, {
2085
+ recordCheckpoints: recordProbeCheckpointsUnlocked,
2086
+ dispatchControl: (controlRequest) => executeControl(controlRequest),
2087
+ }).catch((error) => ({ outcome: 'rejected',
2088
+ detail: String(error?.message ?? error), finding_digest: null }));
2089
+ if (decided === null) continue;
2090
+ current = await readBoundedJson(path.join(runDir, 'events.json'), 'run events');
2091
+ // holdが掛かったら、残りの警報は同じfreezeの下にある。二重にholdを試みない。
2092
+ if (projectRuntimeState({ events: current }).freeze !== null) break;
2093
+ }
2094
+ return current;
2095
+ };
2096
+
1990
2097
  /** 警報の処理を直列化する鎖。`onWarning`はここへ繋ぐだけにする。 */
1991
2098
  let escalationChain = Promise.resolve();
1992
2099
 
@@ -2115,18 +2222,24 @@ export async function runManagedSupervisorDaemon({
2115
2222
  return settled;
2116
2223
  },
2117
2224
  });
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
- });
2225
+ epochDriveActive = true;
2226
+ try {
2227
+ await driveInitialScriptedManagedEpoch({
2228
+ runDir,
2229
+ repoRoot,
2230
+ request,
2231
+ committed,
2232
+ activation,
2233
+ managedSupervisor,
2234
+ sentinel,
2235
+ initialEvents: events,
2236
+ controlEvents: () => controlEvents,
2237
+ preDispatchBindings,
2238
+ drainEscalations: drainPendingEscalations,
2239
+ });
2240
+ } finally {
2241
+ epochDriveActive = false;
2242
+ }
2130
2243
  }
2131
2244
  if (restarting) {
2132
2245
  await managedSupervisor.recoveryBarrier({ barrierId: `recovery-${randomUUID()}`,
@@ -31,8 +31,8 @@ const WIRES = Object.freeze({
31
31
  dispatch: {
32
32
  request: ['schema', 'request_id', 'registration_digest', 'packet', 'write_lease', 'request_digest'],
33
33
  requestSchema: 'lattice.adapter_dispatch_request.v1',
34
- response: ['schema', 'request_id', 'executor_handle', 'worktree_id', 'packet_digest', 'lease_digest', 'response_digest'],
35
- responseSchema: 'lattice.adapter_dispatch_response.v1',
34
+ response: ['schema', 'request_id', 'executor_handle', 'worktree_id', 'packet_digest', 'lease_digest', 'worker_process', 'response_digest'],
35
+ responseSchema: 'lattice.adapter_dispatch_response.v2',
36
36
  },
37
37
  observe: {
38
38
  request: ['schema', 'request_id', 'registration_digest', 'executor_handle', 'expected_epoch', 'expected_lease_digest', 'request_digest'],
@@ -338,8 +338,12 @@ export function validateControllerResponse(operation, value, expectedRequestId =
338
338
  if (!(Boolean(wire) && exact(value, wire.response) && value.schema === wire.responseSchema
339
339
  && identifier(value.request_id) && (expectedRequestId === null || value.request_id === expectedRequestId)
340
340
  && selfValid(value, 'response_digest'))) return false;
341
+ // v2で`worker_process`を必須にした。executorがcontroller自身のprocessだと、holdが
342
+ // 要求する静止を証明できない(止めれば応答できず、止めなければ証明できない)。
343
+ // 誰を止めればよいかをdispatchの時点で名指しさせる。
341
344
  if (operation === 'dispatch') return identifier(value.executor_handle) && identifier(value.worktree_id)
342
- && digest(value.packet_digest) && digest(value.lease_digest);
345
+ && digest(value.packet_digest) && digest(value.lease_digest)
346
+ && validateExpectedWorkerProcess(value.worker_process);
343
347
  if (operation === 'observe') return exact(value.observation, ['schema', 'state', 'executor_handle', 'plan_epoch', 'lease_digest', 'payload_digest', 'observation_digest'])
344
348
  && value.observation.schema === 'lattice.adapter_observation.v1'
345
349
  && ['running', 'checkpoint_ready', 'terminal', 'held'].includes(value.observation.state)
@@ -397,6 +401,20 @@ function validateProtocolRunningBinding(value) {
397
401
  && identifier(value.write_lease_id) && digest(value.controller_registration_digest);
398
402
  }
399
403
 
404
+ /**
405
+ * dispatchが名指しするworker process。直接OS観測が期待するchild processと同じ形にする
406
+ * ——照合先の形が分かれると、supervisorとcontrollerが別のものを見ていても気づけない。
407
+ */
408
+ export function validateExpectedWorkerProcess(value) {
409
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
410
+ && Object.keys(value).sort().join('\0')
411
+ === ['pid', 'process_group_id', 'process_start_identity'].sort().join('\0')
412
+ && Number.isSafeInteger(value.pid) && value.pid > 0
413
+ && Number.isSafeInteger(value.process_group_id) && value.process_group_id > 0
414
+ && validateProcessStartIdentity(value.process_start_identity)
415
+ && value.process_start_identity.pid === value.pid;
416
+ }
417
+
400
418
  export function validateQuiescenceAck(value) {
401
419
  return exact(value, ['schema', 'ack_id', 'run_id', 'todo_id', 'executor_handle', 'worktree_id', 'plan_epoch', 'packet_digest', 'write_lease_id', 'barrier_control_digest', 'final_checkpoint_digest', 'process_observation_digest', 'worktree_fingerprint_digest', 'supervisor_session_nonce_digest', 'ack_digest'])
402
420
  && value.schema === 'lattice.executor_quiescence_ack.v1'
@@ -1,6 +1,6 @@
1
1
  import net from 'node:net';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { execFile } from 'node:child_process';
3
+ import { execFile, spawn } from 'node:child_process';
4
4
  import { constants as fsConstants, readFileSync } from 'node:fs';
5
5
  import {
6
6
  chmod,
@@ -13,6 +13,7 @@ import {
13
13
  rm,
14
14
  } from 'node:fs/promises';
15
15
  import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
16
17
  import { promisify } from 'node:util';
17
18
 
18
19
  import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
@@ -29,6 +30,7 @@ import {
29
30
  validateExecutorPacket,
30
31
  validateExecutorReceipt,
31
32
  } from './runtime-contracts.mjs';
33
+ import { captureWorktreeDiff } from './runtime-diff-observer.mjs';
32
34
  import { validateSupervisorWriteGate } from './runtime-gate-store.mjs';
33
35
  import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
34
36
  import { scriptedWorktreeId, scriptedWorktreePath } from './runtime-scripted-worktree.mjs';
@@ -251,7 +253,13 @@ function deterministicWriteBytes(packet, relativePath) {
251
253
  })}\n`);
252
254
  }
253
255
 
254
- async function executePacket({ packet, repoRoot, extraWrites = [] }) {
256
+ /**
257
+ * packetの宣言writeを実行する。**worker processから呼ばれる。**
258
+ *
259
+ * controllerと同じprocessで走らせていた頃、このrunには走行中のTODOが存在せず、
260
+ * 実行時の観測も静止の証明も原理的に成立しなかった(ADR 0143 Decision 7・9)。
261
+ */
262
+ export async function executePacket({ packet, repoRoot, extraWrites = [] }) {
255
263
  const writes = packet.scope?.writes;
256
264
  if (!Array.isArray(writes) || writes.length === 0
257
265
  || writes.some((entry, index) => !safeWritePath(entry)
@@ -382,6 +390,62 @@ async function readScriptedBehavior(repoRoot) {
382
390
  return { hold_ms: holdMs, extra_writes: [...extraWrites] };
383
391
  }
384
392
 
393
+ const WORKER_ENTRYPOINT = fileURLToPath(new URL('../bin/lattice-scripted-worker.mjs', import.meta.url));
394
+
395
+ /**
396
+ * worker processを起こす(ADR 0143 Decision 9)。
397
+ *
398
+ * `detached`で独立process groupへ置く。controllerと同じgroupに居ると、直接OS観測が
399
+ * 「未記録process group memberを検出」として落とす——正しく落ちるので、ここを外さない。
400
+ *
401
+ * 起動直後にprocess start identityを観測して束ねる。pidは再利用されうるので、
402
+ * pidだけでは「同じprocessか」を後から言えない。
403
+ */
404
+ async function spawnScriptedWorker({ packet, worktreePath, extraWrites, holdMs,
405
+ controllerId, runDir }) {
406
+ const job = {
407
+ schema: 'lattice.scripted_worker_job.v1',
408
+ packet: structuredClone(packet),
409
+ worktree_path: worktreePath,
410
+ extra_writes: [...extraWrites],
411
+ hold_ms: holdMs,
412
+ };
413
+ const jobPath = path.join(runDir, 'controllers', controllerId, 'jobs',
414
+ `${packet.packet_digest}.json`);
415
+ await durableReplaceBytes(jobPath, Buffer.from(`${canonicalizeArtifact(job)}\n`));
416
+ const child = spawn(process.execPath, [WORKER_ENTRYPOINT, jobPath], {
417
+ detached: true, stdio: ['ignore', 'pipe', 'pipe'], cwd: worktreePath,
418
+ });
419
+ if (!Number.isSafeInteger(child.pid)) {
420
+ fail('SCRIPTED_EXECUTION_FAILED', 'worker processを起こせない');
421
+ }
422
+ const identity = await observeManagedProcessStartIdentity(child.pid);
423
+ let stdout = '';
424
+ let stderr = '';
425
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
426
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
427
+ const completed = new Promise((resolve, reject) => {
428
+ child.once('error', reject);
429
+ child.once('close', (code, signal) => {
430
+ const line = stdout.split('\n').find((entry) => entry.includes('scripted_worker_result'));
431
+ let parsed = null;
432
+ try { parsed = line === undefined ? null : JSON.parse(line); } catch { parsed = null; }
433
+ if (parsed?.schema !== 'lattice.scripted_worker_result.v1') {
434
+ reject(new TypeError(`worker結果を読めない (${signal ?? code}): ${stderr.trim() || '(出力なし)'}`));
435
+ return;
436
+ }
437
+ resolve({ observedDiff: parsed.observed_diff, checkpointDigest: parsed.checkpoint_digest });
438
+ });
439
+ });
440
+ return {
441
+ pid: child.pid,
442
+ process_group_id: child.pid,
443
+ process_start_identity: identity,
444
+ child,
445
+ completed,
446
+ };
447
+ }
448
+
385
449
  export async function createScriptedAdapterController({
386
450
  bootstrap,
387
451
  runDir,
@@ -446,6 +510,13 @@ export async function createScriptedAdapterController({
446
510
  let currentEpoch = 1;
447
511
  let registrationDigest = null;
448
512
 
513
+ /** dispatch応答が運ぶworker process。誰を止めればよいかをsupervisorへ名指しする。 */
514
+ const workerProcessOf = (task) => ({
515
+ pid: task.worker.pid,
516
+ process_group_id: task.worker.process_group_id,
517
+ process_start_identity: structuredClone(task.worker.process_start_identity),
518
+ });
519
+
449
520
  const persistReceipt = async (receipt) => {
450
521
  const payloadDigest = digestArtifact(receipt);
451
522
  const receiptPath = path.join(
@@ -594,12 +665,13 @@ export async function createScriptedAdapterController({
594
665
  fail('SCRIPTED_DUPLICATE_DISPATCH', '同じTODOへ異なるpacketをdispatchできない');
595
666
  }
596
667
  return sign({
597
- schema: 'lattice.adapter_dispatch_response.v1',
668
+ schema: 'lattice.adapter_dispatch_response.v2',
598
669
  request_id: request.request_id,
599
670
  executor_handle: prior.executorHandle,
600
671
  worktree_id: prior.worktreeId,
601
672
  packet_digest: packet.packet_digest,
602
673
  lease_digest: writeLease.lease_digest,
674
+ worker_process: workerProcessOf(prior),
603
675
  response_digest: '',
604
676
  }, 'response_digest');
605
677
  }
@@ -629,29 +701,33 @@ export async function createScriptedAdapterController({
629
701
  worktree_id: worktreeId,
630
702
  state: 'running',
631
703
  };
704
+ const worktreeReal = await realpath(worktreePath);
705
+ // **workerは別processで起こす。** controller自身のprocessで作業すると、holdが
706
+ // 要求する静止を証明できない——止めれば応答できず、止めなければ証明できない。
707
+ // `detached`で独立process groupへ置く。同じgroupにcontrollerが居ると、直接OS観測が
708
+ // 「未記録process group memberを検出」として正しく落とす。
709
+ const spawned = await spawnScriptedWorker({
710
+ packet, worktreePath: worktreeReal, extraWrites: behavior.extra_writes,
711
+ holdMs: behavior.hold_ms, controllerId, runDir: canonicalRunDir,
712
+ });
632
713
  const task = {
633
714
  packet: structuredClone(packet),
634
715
  lease: structuredClone(writeLease),
635
716
  executorHandle,
636
717
  worktreeId,
718
+ worktreePath: worktreeReal,
637
719
  receipt: null,
638
720
  payloadDigest: digestArtifact(progress),
639
721
  state: 'running',
640
722
  failure: null,
641
723
  settled: null,
724
+ worker: spawned,
642
725
  };
643
726
  tasks.set(executorHandle, task);
644
727
  todoToHandle.set(packet.todo_id, executorHandle);
645
- const worktreeReal = await realpath(worktreePath);
646
728
  task.settled = (async () => {
647
729
  try {
648
- const { observedDiff, checkpointDigest } = await executePacket({
649
- packet, repoRoot: worktreeReal, extraWrites: behavior.extra_writes,
650
- });
651
- // 書いた後も走り続ける。これが実行時観測の窓であり、0ならば窓は存在しない。
652
- if (behavior.hold_ms > 0) {
653
- await new Promise((resolve) => { setTimeout(resolve, behavior.hold_ms); });
654
- }
730
+ const { observedDiff, checkpointDigest } = await spawned.completed;
655
731
  const receipt = sign({
656
732
  schema: 'lattice.executor_receipt.v1',
657
733
  receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
@@ -677,12 +753,13 @@ export async function createScriptedAdapterController({
677
753
  }
678
754
  })();
679
755
  return sign({
680
- schema: 'lattice.adapter_dispatch_response.v1',
756
+ schema: 'lattice.adapter_dispatch_response.v2',
681
757
  request_id: request.request_id,
682
758
  executor_handle: executorHandle,
683
759
  worktree_id: worktreeId,
684
760
  packet_digest: packet.packet_digest,
685
761
  lease_digest: writeLease.lease_digest,
762
+ worker_process: workerProcessOf(task),
686
763
  response_digest: '',
687
764
  }, 'response_digest');
688
765
  }
@@ -742,24 +819,40 @@ export async function createScriptedAdapterController({
742
819
  if (task === undefined || task.packet.todo_id !== binding.todo_id) {
743
820
  fail('SCRIPTED_BARRIER_REJECTED', 'barrierが未知のrunning bindingを含む');
744
821
  }
745
- // barrierは静止の宣言である。走行中の作業を残したままackを返すと、
746
- // 「止まった」と言いながらworktreeが動き続ける。settleを待ってから答える。
747
- if (task.settled !== null) await task.settled;
748
- if (task.failure !== null) {
749
- fail('SCRIPTED_BARRIER_REJECTED', 'barrier対象のworker実行が失敗している', {
750
- reason: task.failure,
822
+ // **barrierは静止の宣言である。** worker processを実際に止め、止まった木を読む。
823
+ // 走行中の作業を残したままackを返すと、「止まった」と言いながらworktreeが動き続ける。
824
+ try {
825
+ process.kill(task.worker.pid, 'SIGSTOP');
826
+ } catch (error) {
827
+ fail('SCRIPTED_BARRIER_REJECTED', 'worker processを止められない', {
828
+ pid: task.worker.pid, reason: String(error?.code ?? error?.message ?? error),
751
829
  });
752
830
  }
753
831
  task.state = 'held';
832
+ // supervisorも同じ観測を独立に行い、3つのdigestの一致を要求する。**こちらが別の形で
833
+ // 作ると、両者が別のものを見ていても気づけない。** 形はdirect OS observerの契約に
834
+ // 揃え、入力(自分のprocess・自分の木)だけを独立に観測する。
835
+ const checkpoint = await captureWorktreeDiff({
836
+ worktreePath: task.worktreePath, baseSha: task.packet.base_sha,
837
+ });
754
838
  const processObservationDigest = digestArtifact({
755
- schema: 'lattice.scripted_process_observation.v1',
756
- executor_handle: task.executorHandle,
757
- state: 'stopped',
839
+ schema: 'lattice.direct_process_observation.v2',
840
+ root: {
841
+ pid: task.worker.pid,
842
+ parent_pid: process.pid,
843
+ process_start_identity_digest: task.worker.process_start_identity.identity_digest,
844
+ process_group_id: task.worker.process_group_id,
845
+ state: 'stopped',
846
+ },
847
+ children: [],
848
+ process_group_id: task.worker.process_group_id,
849
+ quiesced: true,
758
850
  });
759
851
  const worktreeFingerprintDigest = digestArtifact({
760
- schema: 'lattice.scripted_worktree_fingerprint.v1',
852
+ schema: 'lattice.direct_worktree_fingerprint.v1',
761
853
  worktree_id: task.worktreeId,
762
- checkpoint_digest: task.receipt.checkpoint_digest,
854
+ worktree_realpath: task.worktreePath,
855
+ checkpoint_digest: checkpoint.checkpoint_digest,
763
856
  });
764
857
  acks.push(sign({
765
858
  schema: 'lattice.executor_quiescence_ack.v1',
@@ -772,7 +865,7 @@ export async function createScriptedAdapterController({
772
865
  packet_digest: binding.packet_digest,
773
866
  write_lease_id: binding.write_lease_id,
774
867
  barrier_control_digest: request.barrier_control_digest,
775
- final_checkpoint_digest: task.receipt.checkpoint_digest,
868
+ final_checkpoint_digest: checkpoint.checkpoint_digest,
776
869
  process_observation_digest: processObservationDigest,
777
870
  worktree_fingerprint_digest: worktreeFingerprintDigest,
778
871
  supervisor_session_nonce_digest: sessionNonceDigest,