@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.
|
|
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",
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
866
|
-
|
|
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(
|
|
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
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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.
|
|
756
|
-
|
|
757
|
-
|
|
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.
|
|
852
|
+
schema: 'lattice.direct_worktree_fingerprint.v1',
|
|
761
853
|
worktree_id: task.worktreeId,
|
|
762
|
-
|
|
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:
|
|
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,
|