@quolu/lattice 0.12.24 → 0.12.25
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,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
runScriptedAdapterController,
|
|
5
|
+
ScriptedAdapterControllerError,
|
|
6
|
+
} from '../src/runtime-scripted-adapter-controller.mjs';
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
await runScriptedAdapterController();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
const payload = {
|
|
12
|
+
schema: 'lattice.scripted_adapter_error.v1',
|
|
13
|
+
code: error instanceof ScriptedAdapterControllerError
|
|
14
|
+
? error.code
|
|
15
|
+
: 'SCRIPTED_CONTROLLER_FAILED',
|
|
16
|
+
message: String(error?.message ?? error),
|
|
17
|
+
};
|
|
18
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.25",
|
|
4
4
|
"description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
15
|
"lattice": "bin/lattice.mjs",
|
|
16
|
-
"lattice-mcp": "bin/lattice-mcp.mjs"
|
|
16
|
+
"lattice-mcp": "bin/lattice-mcp.mjs",
|
|
17
|
+
"lattice-scripted-adapter": "bin/lattice-scripted-adapter.mjs"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"bin",
|
|
21
|
+
"bin/lattice-scripted-adapter.mjs",
|
|
20
22
|
"src",
|
|
21
23
|
"docs/schemas/lattice.plan_create_input.v1.schema.json",
|
|
22
24
|
"docs/schemas/lattice.plan_create_input.v2.schema.json",
|
|
@@ -58,7 +60,7 @@
|
|
|
58
60
|
"test": "node scripts/run-product-tests.mjs",
|
|
59
61
|
"test:sensor": "npm --prefix sensor test",
|
|
60
62
|
"precheck": "node --check src/bridge-launch-agent.mjs",
|
|
61
|
-
"check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-address.mjs && node --check src/bridge-registrar.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/runtime-adapter-registry.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
|
|
63
|
+
"check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check bin/lattice-scripted-adapter.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-address.mjs && node --check src/bridge-registrar.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/runtime-adapter-registry.mjs && node --check src/runtime-scripted-adapter-controller.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
|
|
62
64
|
"check:project-identity": "node --check src/project-identity.mjs",
|
|
63
65
|
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:project-identity"
|
|
64
66
|
}
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -29,14 +29,18 @@ import {
|
|
|
29
29
|
validateRuntimeBoundaryManifest,
|
|
30
30
|
validateRuntimePlan,
|
|
31
31
|
validRuntimeAbandonReason,
|
|
32
|
+
validateExecutorReceipt,
|
|
32
33
|
verifyRuntimePlanBinding,
|
|
33
34
|
selfDigest,
|
|
34
35
|
} from './runtime-contracts.mjs';
|
|
35
36
|
import {
|
|
37
|
+
adjudicatePendingReceipts,
|
|
36
38
|
buildNextRunEvent,
|
|
37
39
|
buildExecutorPackets,
|
|
38
40
|
closeRunIfComplete,
|
|
41
|
+
dispatchReadyFrontier,
|
|
39
42
|
initializeRunEvents,
|
|
43
|
+
observeExecutor,
|
|
40
44
|
} from './runtime-engine.mjs';
|
|
41
45
|
import {
|
|
42
46
|
computeReadyFrontier,
|
|
@@ -678,6 +682,236 @@ async function withLifecycleLock(runDir, action) {
|
|
|
678
682
|
}
|
|
679
683
|
}
|
|
680
684
|
|
|
685
|
+
async function readScriptedControllerReceipt({
|
|
686
|
+
runDir,
|
|
687
|
+
controllerId,
|
|
688
|
+
payloadDigest,
|
|
689
|
+
}) {
|
|
690
|
+
const receiptPath = path.join(
|
|
691
|
+
runDir,
|
|
692
|
+
'controllers',
|
|
693
|
+
controllerId,
|
|
694
|
+
'receipts',
|
|
695
|
+
`${payloadDigest}.json`,
|
|
696
|
+
);
|
|
697
|
+
const info = await lstat(receiptPath);
|
|
698
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
699
|
+
throw new ManagedRuntimeError(
|
|
700
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
701
|
+
'scripted controller receipt sidecarがregular fileではない',
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const bytes = await readFile(receiptPath);
|
|
705
|
+
let receipt;
|
|
706
|
+
try {
|
|
707
|
+
receipt = JSON.parse(bytes.toString('utf8'));
|
|
708
|
+
} catch {
|
|
709
|
+
throw new ManagedRuntimeError(
|
|
710
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
711
|
+
'scripted controller receipt sidecarのJSONが不正',
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (bytes.toString('utf8') !== `${canonicalizeArtifact(receipt)}\n`
|
|
715
|
+
|| !validateExecutorReceipt(receipt)
|
|
716
|
+
|| digestArtifact(receipt) !== payloadDigest) {
|
|
717
|
+
throw new ManagedRuntimeError(
|
|
718
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
719
|
+
'scripted controller receipt sidecarのdigest bindingが不正',
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
return receipt;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function driveInitialScriptedManagedEpoch({
|
|
726
|
+
runDir,
|
|
727
|
+
repoRoot,
|
|
728
|
+
request,
|
|
729
|
+
committed,
|
|
730
|
+
activation,
|
|
731
|
+
managedSupervisor,
|
|
732
|
+
initialEvents,
|
|
733
|
+
controlEvents,
|
|
734
|
+
}) {
|
|
735
|
+
let events = [...initialEvents];
|
|
736
|
+
const { plan, manifests, executor_packets: packets } = committed.bundle;
|
|
737
|
+
const controllerId = activation.controllerDescriptor.controller_id;
|
|
738
|
+
const registrationDigest = activation.registration.registration_digest;
|
|
739
|
+
const sessionNonceDigest = digestArtifact(activation.sessionNonce);
|
|
740
|
+
const processGroupId = activation.childPid;
|
|
741
|
+
for (;;) {
|
|
742
|
+
const frontier = computeReadyFrontier({ plan, events }).dispatchable;
|
|
743
|
+
if (frontier.length === 0) break;
|
|
744
|
+
await managedSupervisor.barrierAll({
|
|
745
|
+
barrierId: `dispatch-${plan.plan_epoch}-${events.length}`,
|
|
746
|
+
reason: 'initial_scripted_dispatch',
|
|
747
|
+
frozenEventDigest: events.at(-1).event_digest,
|
|
748
|
+
});
|
|
749
|
+
const issuedControlDigest = controlEvents().at(-1)?.event_digest;
|
|
750
|
+
if (typeof issuedControlDigest !== 'string') {
|
|
751
|
+
throw new ManagedRuntimeError(
|
|
752
|
+
'EPOCH_ACTIVATION_INCOMPLETE',
|
|
753
|
+
'initial dispatch leaseのcontrol bindingが無い',
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
const stagedLeases = [];
|
|
757
|
+
for (const todoId of frontier) {
|
|
758
|
+
const packet = packets[todoId];
|
|
759
|
+
const staged = {
|
|
760
|
+
schema: 'lattice.runtime_write_lease.v1',
|
|
761
|
+
lease_id: `lease-${packet.packet_digest.slice(0, 24)}`,
|
|
762
|
+
run_id: request.request_id,
|
|
763
|
+
todo_id: todoId,
|
|
764
|
+
plan_epoch: plan.plan_epoch,
|
|
765
|
+
packet_digest: packet.packet_digest,
|
|
766
|
+
controller_registration_digest: registrationDigest,
|
|
767
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
768
|
+
state: 'staged',
|
|
769
|
+
ttl_ms: 60_000,
|
|
770
|
+
issued_control_digest: issuedControlDigest,
|
|
771
|
+
lease_digest: '',
|
|
772
|
+
};
|
|
773
|
+
staged.lease_digest = selfDigest(staged, 'lease_digest');
|
|
774
|
+
await managedSupervisor.prepareController({
|
|
775
|
+
controllerId,
|
|
776
|
+
executorPacket: packet,
|
|
777
|
+
stagedLease: staged,
|
|
778
|
+
});
|
|
779
|
+
stagedLeases.push(staged);
|
|
780
|
+
}
|
|
781
|
+
const activationDigest = digestArtifact({
|
|
782
|
+
schema: 'lattice.initial_scripted_activation.v1',
|
|
783
|
+
committed_epoch_pointer_digest: committed.pointer.pointer_digest,
|
|
784
|
+
staged_lease_digests: stagedLeases.map((lease) => lease.lease_digest).sort(),
|
|
785
|
+
});
|
|
786
|
+
const activated = await managedSupervisor.commitWriteGate({
|
|
787
|
+
planEpoch: plan.plan_epoch,
|
|
788
|
+
committedEpochDigest: committed.pointer.pointer_digest,
|
|
789
|
+
activationDigest,
|
|
790
|
+
commitReleaseBarrier: (barrier) => commitReleaseEpochBarrier({ runDir, barrier }),
|
|
791
|
+
committedAt: canonicalNow(),
|
|
792
|
+
});
|
|
793
|
+
const armedByPacket = new Map(activated.armedLeases.map((lease) => [
|
|
794
|
+
lease.packet_digest,
|
|
795
|
+
lease,
|
|
796
|
+
]));
|
|
797
|
+
const managedAdapter = {
|
|
798
|
+
async dispatch({ packet }) {
|
|
799
|
+
const lease = armedByPacket.get(packet.packet_digest);
|
|
800
|
+
if (lease === undefined) {
|
|
801
|
+
throw new ManagedRuntimeError(
|
|
802
|
+
'EPOCH_ACTIVATION_INCOMPLETE',
|
|
803
|
+
`armed leaseが無い: ${packet.todo_id}`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
await managedSupervisor.authorizeWrite({ leaseDigest: lease.lease_digest });
|
|
807
|
+
const response = await managedSupervisor.route('dispatch', controllerId, {
|
|
808
|
+
packet,
|
|
809
|
+
write_lease: lease,
|
|
810
|
+
});
|
|
811
|
+
if (response.packet_digest !== packet.packet_digest
|
|
812
|
+
|| response.lease_digest !== lease.lease_digest) {
|
|
813
|
+
throw new ManagedRuntimeError(
|
|
814
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
815
|
+
`dispatch response binding不一致: ${packet.todo_id}`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
executor_handle: response.executor_handle,
|
|
820
|
+
worktree_id: response.worktree_id,
|
|
821
|
+
write_lease_id: lease.lease_id,
|
|
822
|
+
write_lease_digest: lease.lease_digest,
|
|
823
|
+
controller_registration_digest: registrationDigest,
|
|
824
|
+
controller_session_nonce_digest:
|
|
825
|
+
activation.controllerDescriptor.controller_session_nonce_digest,
|
|
826
|
+
direct_os_observation_binding: {
|
|
827
|
+
process_pid: activation.childPid,
|
|
828
|
+
process_group_id: processGroupId,
|
|
829
|
+
process_start_identity:
|
|
830
|
+
structuredClone(activation.controllerDescriptor.process_start_identity),
|
|
831
|
+
worktree_path: repoRoot,
|
|
832
|
+
base_sha: packet.base_sha,
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
async observe({ executor_handle: executorHandle }) {
|
|
837
|
+
const dispatch = events.findLast((event) => (
|
|
838
|
+
event.kind === 'executor_dispatched'
|
|
839
|
+
&& event.payload?.executor_handle === executorHandle
|
|
840
|
+
));
|
|
841
|
+
const response = await managedSupervisor.route('observe', controllerId, {
|
|
842
|
+
executor_handle: executorHandle,
|
|
843
|
+
expected_epoch: dispatch.plan_epoch,
|
|
844
|
+
expected_lease_digest: dispatch.payload.write_lease_digest,
|
|
845
|
+
});
|
|
846
|
+
if (response.observation.state !== 'terminal') {
|
|
847
|
+
throw new ManagedRuntimeError(
|
|
848
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
849
|
+
`scripted controllerがterminal以外を返した: ${response.observation.state}`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
const receipt = await readScriptedControllerReceipt({
|
|
853
|
+
runDir,
|
|
854
|
+
controllerId,
|
|
855
|
+
payloadDigest: response.observation.payload_digest,
|
|
856
|
+
});
|
|
857
|
+
return { state: 'terminal', receipt };
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
const dispatched = await dispatchReadyFrontier({
|
|
861
|
+
runId: request.request_id,
|
|
862
|
+
plan,
|
|
863
|
+
events,
|
|
864
|
+
packets,
|
|
865
|
+
manifests,
|
|
866
|
+
adapter: managedAdapter,
|
|
867
|
+
recordedAt: canonicalNow(),
|
|
868
|
+
});
|
|
869
|
+
if (dispatched.failure !== null) {
|
|
870
|
+
throw new ManagedRuntimeError(
|
|
871
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
872
|
+
`scripted dispatch失敗: ${dispatched.failure.todo_id}: ${dispatched.failure.message}`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
events = dispatched.events;
|
|
876
|
+
await replaceEventsAtomically(runDir, events);
|
|
877
|
+
for (const todoId of dispatched.dispatched) {
|
|
878
|
+
const observed = await observeExecutor({
|
|
879
|
+
runId: request.request_id,
|
|
880
|
+
todoId,
|
|
881
|
+
plan,
|
|
882
|
+
events,
|
|
883
|
+
adapter: managedAdapter,
|
|
884
|
+
recordedAt: canonicalNow(),
|
|
885
|
+
});
|
|
886
|
+
events = observed.events;
|
|
887
|
+
await replaceEventsAtomically(runDir, events);
|
|
888
|
+
}
|
|
889
|
+
const adjudicated = adjudicatePendingReceipts({
|
|
890
|
+
runId: request.request_id,
|
|
891
|
+
plan,
|
|
892
|
+
events,
|
|
893
|
+
recordedAt: canonicalNow(),
|
|
894
|
+
});
|
|
895
|
+
if (adjudicated.decisions.some((decision) => decision.decision !== 'accepted')) {
|
|
896
|
+
throw new ManagedRuntimeError(
|
|
897
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
898
|
+
'scripted controller receiptが受理されなかった',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
events = adjudicated.events;
|
|
902
|
+
await replaceEventsAtomically(runDir, events);
|
|
903
|
+
}
|
|
904
|
+
return events;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function isDistributedScriptedControllerActivation(activation) {
|
|
908
|
+
return activation?.controllerDescriptor?.adapter_kind === 'scripted'
|
|
909
|
+
&& activation?.launchDescriptor?.launch_kind === 'host_binary'
|
|
910
|
+
&& activation.launchDescriptor.argv.some((argument) => (
|
|
911
|
+
path.basename(argument) === 'lattice-scripted-adapter.mjs'
|
|
912
|
+
));
|
|
913
|
+
}
|
|
914
|
+
|
|
681
915
|
async function runStart({ requestPath, executorAdapter, cwd, stdout }) {
|
|
682
916
|
// --executor省略時の暗黙fallbackは持たない(Decision 8)。未知adapterはtyped reject。
|
|
683
917
|
if (!KNOWN_ADAPTERS.includes(executorAdapter)) {
|
|
@@ -1520,6 +1754,18 @@ export async function runManagedSupervisorDaemon({
|
|
|
1520
1754
|
for (const extra of additionalActivations) {
|
|
1521
1755
|
await extra.registerWithManagedSupervisor(managedSupervisor);
|
|
1522
1756
|
}
|
|
1757
|
+
if (!restarting && isDistributedScriptedControllerActivation(activation)) {
|
|
1758
|
+
await driveInitialScriptedManagedEpoch({
|
|
1759
|
+
runDir,
|
|
1760
|
+
repoRoot,
|
|
1761
|
+
request,
|
|
1762
|
+
committed,
|
|
1763
|
+
activation,
|
|
1764
|
+
managedSupervisor,
|
|
1765
|
+
initialEvents: events,
|
|
1766
|
+
controlEvents: () => controlEvents,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1523
1769
|
if (restarting) {
|
|
1524
1770
|
await managedSupervisor.recoveryBarrier({ barrierId: `recovery-${randomUUID()}`,
|
|
1525
1771
|
frozenEventDigest: events.at(-1).event_digest });
|
package/src/runtime-engine.mjs
CHANGED
|
@@ -292,18 +292,34 @@ export async function dispatchReadyFrontier(options = {}) {
|
|
|
292
292
|
|| dispatchResult.worktree_id.length === 0) {
|
|
293
293
|
fail(`adapter dispatchがopaque handle/worktreeを返さない: ${todoId}`);
|
|
294
294
|
}
|
|
295
|
+
const dispatchPayload = {
|
|
296
|
+
executor_handle: dispatchResult.executor_handle,
|
|
297
|
+
worktree_id: dispatchResult.worktree_id,
|
|
298
|
+
packet_digest: packet.packet_digest,
|
|
299
|
+
context_content_digest: packet.context_content_digest,
|
|
300
|
+
};
|
|
301
|
+
const managedFields = [
|
|
302
|
+
'write_lease_id',
|
|
303
|
+
'write_lease_digest',
|
|
304
|
+
'controller_registration_digest',
|
|
305
|
+
'controller_session_nonce_digest',
|
|
306
|
+
'direct_os_observation_binding',
|
|
307
|
+
];
|
|
308
|
+
if (managedFields.some((field) => Object.hasOwn(dispatchResult, field))) {
|
|
309
|
+
if (!managedFields.every((field) => Object.hasOwn(dispatchResult, field))) {
|
|
310
|
+
fail(`managed adapter dispatch bindingが不足する: ${todoId}`);
|
|
311
|
+
}
|
|
312
|
+
Object.assign(dispatchPayload, Object.fromEntries(
|
|
313
|
+
managedFields.map((field) => [field, structuredClone(dispatchResult[field])]),
|
|
314
|
+
));
|
|
315
|
+
}
|
|
295
316
|
next.push(buildNextRunEvent({
|
|
296
317
|
events: next,
|
|
297
318
|
runId,
|
|
298
319
|
kind: 'executor_dispatched',
|
|
299
320
|
planEpoch: plan.plan_epoch,
|
|
300
321
|
subject: { kind: 'todo', ref: todoId },
|
|
301
|
-
payload:
|
|
302
|
-
executor_handle: dispatchResult.executor_handle,
|
|
303
|
-
worktree_id: dispatchResult.worktree_id,
|
|
304
|
-
packet_digest: packet.packet_digest,
|
|
305
|
-
context_content_digest: packet.context_content_digest,
|
|
306
|
-
},
|
|
322
|
+
payload: dispatchPayload,
|
|
307
323
|
recordedAt,
|
|
308
324
|
}));
|
|
309
325
|
dispatchedNow.push(todoId);
|
|
@@ -699,6 +699,13 @@ function createControllerSocketTransport(socketPath, timeoutMs) {
|
|
|
699
699
|
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
700
700
|
let document;
|
|
701
701
|
try { document = JSON.parse(line); } catch { failPending('controller document JSON不正'); socket.destroy(); return; }
|
|
702
|
+
if (document?.schema === 'lattice.scripted_adapter_error.v1'
|
|
703
|
+
&& typeof document.code === 'string'
|
|
704
|
+
&& typeof document.message === 'string') {
|
|
705
|
+
failPending(`${document.code}: ${document.message}`);
|
|
706
|
+
socket.destroy();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
702
709
|
if (validateControllerHeartbeat(document)) {
|
|
703
710
|
Promise.resolve(heartbeatHandler?.(structuredClone(document))).catch(() => socket.destroy());
|
|
704
711
|
continue;
|
|
@@ -755,6 +762,7 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
|
|
|
755
762
|
const controllerSocketPath = path.join(runDir, controllerSocketRef);
|
|
756
763
|
const supervisorSocketRef = 'supervisor/control.sock';
|
|
757
764
|
let child = null;
|
|
765
|
+
let childStderr = '';
|
|
758
766
|
try {
|
|
759
767
|
await mkdir(controllerDir, { recursive: true, mode: 0o700 });
|
|
760
768
|
let handshakeSocket = launch.endpoint;
|
|
@@ -775,7 +783,15 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
|
|
|
775
783
|
if (!config.isFile() || config.isSymbolicLink() || sha256Bytes(await readFile(configPath)) !== launch.config_digest) fail('ADAPTER_LAUNCH_INVALID', 'config digest不一致');
|
|
776
784
|
const bootstrap = createControllerBootstrap({ requestId: randomUUID(), runId, controllerSocketRef, supervisorSocketRef, supervisorSessionNonce });
|
|
777
785
|
// controller hostのcwdはrun store。bootstrapの固定relative socket refを任意absolute pathへ拡張しない。
|
|
778
|
-
child = spawn(binaryReal, launch.argv, {
|
|
786
|
+
child = spawn(binaryReal, launch.argv, {
|
|
787
|
+
cwd: runDir,
|
|
788
|
+
detached: true,
|
|
789
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe'],
|
|
790
|
+
});
|
|
791
|
+
child.stderr.setEncoding('utf8');
|
|
792
|
+
child.stderr.on('data', (chunk) => {
|
|
793
|
+
childStderr = `${childStderr}${chunk}`.slice(-8_192);
|
|
794
|
+
});
|
|
779
795
|
child.stdio[3].write(`${canonicalizeArtifact(bootstrap)}\n`);
|
|
780
796
|
child.stdio[3].end();
|
|
781
797
|
handshakeSocket = controllerSocketPath;
|
|
@@ -783,11 +799,23 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
|
|
|
783
799
|
const deadline = Date.now() + timeoutMs;
|
|
784
800
|
while (Date.now() < deadline) {
|
|
785
801
|
try { if ((await lstat(handshakeSocket)).isSocket()) break; } catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
786
|
-
if (child.exitCode !== null)
|
|
802
|
+
if (child.exitCode !== null) {
|
|
803
|
+
fail(
|
|
804
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
805
|
+
`controller exited: ${child.exitCode}${childStderr ? `: ${childStderr.trim()}` : ''}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
787
808
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
788
809
|
}
|
|
789
810
|
let socketInfo;
|
|
790
|
-
try {
|
|
811
|
+
try {
|
|
812
|
+
socketInfo = await lstat(handshakeSocket);
|
|
813
|
+
} catch {
|
|
814
|
+
fail(
|
|
815
|
+
'ADAPTER_CONTROLLER_UNAVAILABLE',
|
|
816
|
+
`controller socket未生成${childStderr ? `: ${childStderr.trim()}` : ''}`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
791
819
|
if (!socketInfo.isSocket()) fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'controller endpointがsocketでない');
|
|
792
820
|
// exec後にも同じ実行image bytesを再検証する。PID生存も同時に要求する。
|
|
793
821
|
try { process.kill(child.pid, 0); } catch { fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'controller process不達'); }
|
|
@@ -902,7 +930,18 @@ async function activateManagedSupervisorController({ repoRoot, runDir, runId, ad
|
|
|
902
930
|
await registerWithManagedSupervisor(managedSupervisor);
|
|
903
931
|
return managedSupervisor;
|
|
904
932
|
};
|
|
905
|
-
return {
|
|
933
|
+
return {
|
|
934
|
+
supervisorDescriptor,
|
|
935
|
+
activationControlEvent,
|
|
936
|
+
controllerDescriptor,
|
|
937
|
+
registration,
|
|
938
|
+
launchDescriptor: structuredClone(launch),
|
|
939
|
+
sessionNonce: supervisorSessionNonce,
|
|
940
|
+
childPid: child?.pid ?? controllerDescriptor.pid,
|
|
941
|
+
createManagedSupervisor,
|
|
942
|
+
registerWithManagedSupervisor,
|
|
943
|
+
disposeController,
|
|
944
|
+
};
|
|
906
945
|
} catch (error) {
|
|
907
946
|
if (child?.pid) { try { process.kill(child.pid, 'SIGTERM'); } catch { /* already exited */ } }
|
|
908
947
|
await rm(controllerSocketPath, { force: true }).catch(() => {});
|
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { constants as fsConstants, readFileSync } from 'node:fs';
|
|
5
|
+
import {
|
|
6
|
+
chmod,
|
|
7
|
+
lstat,
|
|
8
|
+
mkdir,
|
|
9
|
+
open,
|
|
10
|
+
readFile,
|
|
11
|
+
realpath,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
|
|
18
|
+
import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
|
|
19
|
+
import {
|
|
20
|
+
armStagedWriteLease,
|
|
21
|
+
CONTROLLER_OPERATIONS,
|
|
22
|
+
validateArmedWriteLease,
|
|
23
|
+
validateControllerRequest,
|
|
24
|
+
validateRuntimeAdapterCapabilities,
|
|
25
|
+
validateRuntimeHeartbeatPolicy,
|
|
26
|
+
} from './runtime-controller-protocol.mjs';
|
|
27
|
+
import {
|
|
28
|
+
selfDigest,
|
|
29
|
+
validateExecutorPacket,
|
|
30
|
+
validateExecutorReceipt,
|
|
31
|
+
} from './runtime-contracts.mjs';
|
|
32
|
+
import { validateSupervisorWriteGate } from './runtime-gate-store.mjs';
|
|
33
|
+
import { observeManagedProcessStartIdentity } from './runtime-managed-supervisor.mjs';
|
|
34
|
+
|
|
35
|
+
const MAX_DOCUMENT_BYTES = 8_388_608;
|
|
36
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
37
|
+
const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
const REQUEST_SCHEMA_TO_OPERATION = Object.freeze(Object.fromEntries([
|
|
40
|
+
['lattice.adapter_dispatch_request.v1', 'dispatch'],
|
|
41
|
+
['lattice.adapter_observe_request.v1', 'observe'],
|
|
42
|
+
['lattice.adapter_running_inventory_request.v1', 'inventory'],
|
|
43
|
+
['lattice.adapter_barrier_request.v1', 'barrier'],
|
|
44
|
+
['lattice.adapter_rebind_request.v1', 'rebind'],
|
|
45
|
+
['lattice.adapter_prepare_request.v1', 'prepare'],
|
|
46
|
+
['lattice.adapter_activate_request.v1', 'activate'],
|
|
47
|
+
['lattice.adapter_release_request.v1', 'release'],
|
|
48
|
+
['lattice.adapter_revoke_request.v1', 'revoke'],
|
|
49
|
+
]));
|
|
50
|
+
|
|
51
|
+
export class ScriptedAdapterControllerError extends Error {
|
|
52
|
+
constructor(code, message, detail = {}) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'ScriptedAdapterControllerError';
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.detail = detail;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fail(code, message, detail) {
|
|
61
|
+
throw new ScriptedAdapterControllerError(code, message, detail);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function plain(value) {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
66
|
+
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function exact(value, fields) {
|
|
70
|
+
if (!plain(value)) return false;
|
|
71
|
+
const actual = Object.keys(value).sort();
|
|
72
|
+
const expected = [...fields].sort();
|
|
73
|
+
return actual.length === expected.length
|
|
74
|
+
&& actual.every((field, index) => field === expected[index]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sign(value, field) {
|
|
78
|
+
value[field] = '';
|
|
79
|
+
value[field] = selfDigest(value, field);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sha256Bytes(bytes) {
|
|
84
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function controllerErrorArtifact(error, requestId = null) {
|
|
88
|
+
const artifact = {
|
|
89
|
+
schema: 'lattice.scripted_adapter_error.v1',
|
|
90
|
+
code: error?.code ?? 'SCRIPTED_CONTROLLER_FAILED',
|
|
91
|
+
message: String(error?.message ?? error),
|
|
92
|
+
request_id: typeof requestId === 'string' ? requestId : null,
|
|
93
|
+
detail: plain(error?.detail) ? structuredClone(error.detail) : {},
|
|
94
|
+
error_digest: '',
|
|
95
|
+
};
|
|
96
|
+
artifact.error_digest = selfDigest(artifact, 'error_digest');
|
|
97
|
+
return artifact;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateBootstrap(value) {
|
|
101
|
+
return exact(value, [
|
|
102
|
+
'schema',
|
|
103
|
+
'request_id',
|
|
104
|
+
'run_id',
|
|
105
|
+
'controller_socket_ref',
|
|
106
|
+
'supervisor_socket_ref',
|
|
107
|
+
'supervisor_session_nonce',
|
|
108
|
+
'bootstrap_digest',
|
|
109
|
+
])
|
|
110
|
+
&& value.schema === 'lattice.adapter_controller_bootstrap.v1'
|
|
111
|
+
&& ID.test(value.request_id ?? '')
|
|
112
|
+
&& ID.test(value.run_id ?? '')
|
|
113
|
+
&& typeof value.controller_socket_ref === 'string'
|
|
114
|
+
&& /^supervisor\/controllers\/[0-9A-Za-z][0-9A-Za-z._-]{0,127}\.sock$/u
|
|
115
|
+
.test(value.controller_socket_ref)
|
|
116
|
+
&& value.supervisor_socket_ref === 'supervisor/control.sock'
|
|
117
|
+
&& typeof value.supervisor_session_nonce === 'string'
|
|
118
|
+
&& value.supervisor_session_nonce.length >= 32
|
|
119
|
+
&& SHA256.test(value.bootstrap_digest ?? '')
|
|
120
|
+
&& selfDigest(value, 'bootstrap_digest') === value.bootstrap_digest;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function validateHandshakeRequest(value, bootstrap) {
|
|
124
|
+
return exact(value, [
|
|
125
|
+
'schema',
|
|
126
|
+
'request_id',
|
|
127
|
+
'run_id',
|
|
128
|
+
'supervisor_session_nonce',
|
|
129
|
+
'controller_socket_ref',
|
|
130
|
+
'challenge',
|
|
131
|
+
'request_digest',
|
|
132
|
+
])
|
|
133
|
+
&& value.schema === 'lattice.adapter_controller_handshake_request.v1'
|
|
134
|
+
&& ID.test(value.request_id ?? '')
|
|
135
|
+
&& value.run_id === bootstrap.run_id
|
|
136
|
+
&& value.supervisor_session_nonce === bootstrap.supervisor_session_nonce
|
|
137
|
+
&& value.controller_socket_ref === bootstrap.controller_socket_ref
|
|
138
|
+
&& typeof value.challenge === 'string'
|
|
139
|
+
&& value.challenge.length >= 32
|
|
140
|
+
&& SHA256.test(value.request_digest ?? '')
|
|
141
|
+
&& selfDigest(value, 'request_digest') === value.request_digest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeWritePath(value) {
|
|
145
|
+
return typeof value === 'string'
|
|
146
|
+
&& value.length > 0
|
|
147
|
+
&& value === path.posix.normalize(value)
|
|
148
|
+
&& !path.posix.isAbsolute(value)
|
|
149
|
+
&& value !== '..'
|
|
150
|
+
&& !value.startsWith('../')
|
|
151
|
+
&& !value.includes('\0')
|
|
152
|
+
&& !['.git', '.lattice'].includes(value.split('/')[0]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function durableReplaceBytes(filePath, bytes, mode = 0o600) {
|
|
156
|
+
const directory = path.dirname(filePath);
|
|
157
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
158
|
+
const temporaryPath = path.join(directory, `.${path.basename(filePath)}-${process.pid}.tmp`);
|
|
159
|
+
let handle;
|
|
160
|
+
try {
|
|
161
|
+
handle = await open(
|
|
162
|
+
temporaryPath,
|
|
163
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
164
|
+
mode,
|
|
165
|
+
);
|
|
166
|
+
await handle.writeFile(bytes);
|
|
167
|
+
await handle.sync();
|
|
168
|
+
await handle.close();
|
|
169
|
+
handle = null;
|
|
170
|
+
await rename(temporaryPath, filePath);
|
|
171
|
+
await chmod(filePath, mode);
|
|
172
|
+
const directoryHandle = await open(directory, fsConstants.O_RDONLY);
|
|
173
|
+
try {
|
|
174
|
+
await directoryHandle.sync();
|
|
175
|
+
} finally {
|
|
176
|
+
await directoryHandle.close();
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
await handle?.close().catch(() => {});
|
|
180
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function requireSafeTarget(repoRoot, relativePath) {
|
|
185
|
+
if (!safeWritePath(relativePath)) {
|
|
186
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write pathが安全なrepo-relative pathではない', {
|
|
187
|
+
path: relativePath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const target = path.join(repoRoot, ...relativePath.split('/'));
|
|
191
|
+
if (!target.startsWith(`${repoRoot}${path.sep}`)) {
|
|
192
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write pathがrepo外を指す', { path: relativePath });
|
|
193
|
+
}
|
|
194
|
+
let cursor = repoRoot;
|
|
195
|
+
for (const segment of relativePath.split('/').slice(0, -1)) {
|
|
196
|
+
cursor = path.join(cursor, segment);
|
|
197
|
+
try {
|
|
198
|
+
const info = await lstat(cursor);
|
|
199
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
200
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write path祖先がreal directoryではない', {
|
|
201
|
+
path: relativePath,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
210
|
+
const parentReal = await realpath(path.dirname(target));
|
|
211
|
+
if (!parentReal.startsWith(`${repoRoot}${path.sep}`) && parentReal !== repoRoot) {
|
|
212
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write path親がrepo外へ解決された', { path: relativePath });
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const info = await lstat(target);
|
|
216
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
217
|
+
fail('SCRIPTED_PACKET_REJECTED', 'write targetがregular fileではない', {
|
|
218
|
+
path: relativePath,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
223
|
+
}
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function basePathExists(repoRoot, baseSha, relativePath) {
|
|
228
|
+
try {
|
|
229
|
+
await execFileAsync('git', ['cat-file', '-e', `${baseSha}:${relativePath}`], {
|
|
230
|
+
cwd: repoRoot,
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
});
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error?.code === 128) return false;
|
|
236
|
+
fail('SCRIPTED_EXECUTION_FAILED', 'base treeのwrite pathを観測できない', {
|
|
237
|
+
path: relativePath,
|
|
238
|
+
message: String(error?.message ?? error),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function deterministicWriteBytes(packet, relativePath) {
|
|
244
|
+
return Buffer.from(`${canonicalizeArtifact({
|
|
245
|
+
schema: 'lattice.scripted_adapter_write.v1',
|
|
246
|
+
todo_id: packet.todo_id,
|
|
247
|
+
path: relativePath,
|
|
248
|
+
packet_digest: packet.packet_digest,
|
|
249
|
+
context_content_digest: packet.context_content_digest,
|
|
250
|
+
})}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function executePacket({ packet, repoRoot }) {
|
|
254
|
+
const writes = packet.scope?.writes;
|
|
255
|
+
if (!Array.isArray(writes) || writes.length === 0
|
|
256
|
+
|| writes.some((entry, index) => !safeWritePath(entry)
|
|
257
|
+
|| (index > 0 && writes[index - 1] >= entry))) {
|
|
258
|
+
fail('SCRIPTED_PACKET_REJECTED', 'scope.writesは非空の昇順一意pathでなければならない');
|
|
259
|
+
}
|
|
260
|
+
const prepared = [];
|
|
261
|
+
for (const relativePath of writes) {
|
|
262
|
+
const target = await requireSafeTarget(repoRoot, relativePath);
|
|
263
|
+
const existedAtBase = await basePathExists(repoRoot, packet.base_sha, relativePath);
|
|
264
|
+
prepared.push({
|
|
265
|
+
relativePath,
|
|
266
|
+
target,
|
|
267
|
+
bytes: deterministicWriteBytes(packet, relativePath),
|
|
268
|
+
change: existedAtBase ? 'modified' : 'added',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
for (const entry of prepared) {
|
|
272
|
+
await durableReplaceBytes(entry.target, entry.bytes);
|
|
273
|
+
const observed = await readFile(entry.target);
|
|
274
|
+
if (!observed.equals(entry.bytes)) {
|
|
275
|
+
fail('SCRIPTED_EXECUTION_FAILED', 'write後bytesが決定的内容と一致しない', {
|
|
276
|
+
path: entry.relativePath,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const observedDiff = prepared.map((entry) => ({
|
|
281
|
+
path: entry.relativePath,
|
|
282
|
+
change: entry.change,
|
|
283
|
+
}));
|
|
284
|
+
const checkpointDigest = digestArtifact({
|
|
285
|
+
schema: 'lattice.scripted_adapter_checkpoint.v1',
|
|
286
|
+
packet_digest: packet.packet_digest,
|
|
287
|
+
observed_diff: observedDiff,
|
|
288
|
+
content_digests: prepared.map((entry) => ({
|
|
289
|
+
path: entry.relativePath,
|
|
290
|
+
digest: sha256Bytes(entry.bytes),
|
|
291
|
+
})),
|
|
292
|
+
});
|
|
293
|
+
return { observedDiff, checkpointDigest };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function readAndValidateGate(runDir, writeLease) {
|
|
297
|
+
const gatePath = path.join(runDir, 'supervisor', 'write-gate.json');
|
|
298
|
+
let gate;
|
|
299
|
+
try {
|
|
300
|
+
gate = JSON.parse(await readFile(gatePath, 'utf8'));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
fail('SCRIPTED_WRITE_GATE_REJECTED', '中央write gateを読めない', {
|
|
303
|
+
message: String(error?.message ?? error),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (!validateSupervisorWriteGate(gate)
|
|
307
|
+
|| gate.run_id !== writeLease.run_id
|
|
308
|
+
|| gate.plan_epoch !== writeLease.plan_epoch
|
|
309
|
+
|| gate.gate_generation !== writeLease.gate_generation
|
|
310
|
+
|| gate.release_barrier_digest !== writeLease.release_barrier_digest
|
|
311
|
+
|| !gate.armed_lease_digests.includes(writeLease.lease_digest)) {
|
|
312
|
+
fail('SCRIPTED_WRITE_GATE_REJECTED', '中央write gateがarmed leaseを認可しない');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* supervisor controller protocolを実装する決定論的scripted adapter。
|
|
318
|
+
* 時刻はheartbeat schedulingだけに使い、write bytes/handle/receiptへ混入させない。
|
|
319
|
+
*/
|
|
320
|
+
export async function createScriptedAdapterController({
|
|
321
|
+
bootstrap,
|
|
322
|
+
runDir,
|
|
323
|
+
repoRoot,
|
|
324
|
+
controllerSessionNonce = randomBytes(32).toString('hex'),
|
|
325
|
+
}) {
|
|
326
|
+
if (!validateBootstrap(bootstrap)) {
|
|
327
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'controller bootstrapが不正');
|
|
328
|
+
}
|
|
329
|
+
const canonicalRunDir = await realpath(runDir);
|
|
330
|
+
const canonicalRepoRoot = await realpath(repoRoot);
|
|
331
|
+
if (path.resolve(canonicalRunDir, '..', '..', '..') !== canonicalRepoRoot) {
|
|
332
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'run storeとrepo rootのbindingが不正');
|
|
333
|
+
}
|
|
334
|
+
const controllerId = path.basename(bootstrap.controller_socket_ref, '.sock');
|
|
335
|
+
if (!controllerId.startsWith('scripted-')
|
|
336
|
+
|| typeof controllerSessionNonce !== 'string'
|
|
337
|
+
|| controllerSessionNonce.length < 32) {
|
|
338
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'scripted controller identityが不正');
|
|
339
|
+
}
|
|
340
|
+
const capabilities = sign({
|
|
341
|
+
schema: 'lattice.runtime_adapter_capabilities.v1',
|
|
342
|
+
operations: [...CONTROLLER_OPERATIONS],
|
|
343
|
+
process_observation: true,
|
|
344
|
+
worktree_fingerprint: true,
|
|
345
|
+
staged_write_lease: true,
|
|
346
|
+
durable_dispatch: true,
|
|
347
|
+
capabilities_digest: '',
|
|
348
|
+
}, 'capabilities_digest');
|
|
349
|
+
if (!validateRuntimeAdapterCapabilities(capabilities)) {
|
|
350
|
+
fail('SCRIPTED_CONTROLLER_INVALID', 'capabilities生成に失敗した');
|
|
351
|
+
}
|
|
352
|
+
const heartbeat = sign({
|
|
353
|
+
schema: 'lattice.runtime_heartbeat_policy.v1',
|
|
354
|
+
interval_ms: 1_000,
|
|
355
|
+
ttl_ms: 10_000,
|
|
356
|
+
disconnect_revokes_immediately: true,
|
|
357
|
+
policy_digest: '',
|
|
358
|
+
}, 'policy_digest');
|
|
359
|
+
if (!validateRuntimeHeartbeatPolicy(heartbeat)) {
|
|
360
|
+
fail('SCRIPTED_CONTROLLER_INVALID', 'heartbeat policy生成に失敗した');
|
|
361
|
+
}
|
|
362
|
+
const descriptor = sign({
|
|
363
|
+
schema: 'lattice.runtime_adapter_controller_descriptor.v1',
|
|
364
|
+
controller_id: controllerId,
|
|
365
|
+
adapter_kind: 'scripted',
|
|
366
|
+
pid: process.pid,
|
|
367
|
+
process_start_identity: await observeManagedProcessStartIdentity(process.pid),
|
|
368
|
+
socket_ref: bootstrap.controller_socket_ref,
|
|
369
|
+
controller_session_nonce_digest: digestArtifact(controllerSessionNonce),
|
|
370
|
+
capabilities,
|
|
371
|
+
heartbeat,
|
|
372
|
+
descriptor_digest: '',
|
|
373
|
+
}, 'descriptor_digest');
|
|
374
|
+
const sessionNonceDigest = digestArtifact(bootstrap.supervisor_session_nonce);
|
|
375
|
+
const stagedLeases = new Map();
|
|
376
|
+
const armedLeases = new Map();
|
|
377
|
+
const preparedPackets = new Map();
|
|
378
|
+
const tasks = new Map();
|
|
379
|
+
const todoToHandle = new Map();
|
|
380
|
+
let currentEpoch = 1;
|
|
381
|
+
let registrationDigest = null;
|
|
382
|
+
|
|
383
|
+
const persistReceipt = async (receipt) => {
|
|
384
|
+
const payloadDigest = digestArtifact(receipt);
|
|
385
|
+
const receiptPath = path.join(
|
|
386
|
+
canonicalRunDir,
|
|
387
|
+
'controllers',
|
|
388
|
+
controllerId,
|
|
389
|
+
'receipts',
|
|
390
|
+
`${payloadDigest}.json`,
|
|
391
|
+
);
|
|
392
|
+
await durableReplaceBytes(
|
|
393
|
+
receiptPath,
|
|
394
|
+
Buffer.from(`${canonicalizeArtifact(receipt)}\n`),
|
|
395
|
+
);
|
|
396
|
+
return payloadDigest;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const responseFor = async (operation, request) => {
|
|
400
|
+
if (!validateControllerRequest(operation, request)) {
|
|
401
|
+
fail('SCRIPTED_REQUEST_INVALID', `${operation} requestがprotocol contractを満たさない`);
|
|
402
|
+
}
|
|
403
|
+
if (registrationDigest !== null && request.registration_digest !== registrationDigest) {
|
|
404
|
+
fail('SCRIPTED_REGISTRATION_MISMATCH', 'controller registration digestが変化した');
|
|
405
|
+
}
|
|
406
|
+
registrationDigest = request.registration_digest;
|
|
407
|
+
if (operation === 'prepare') {
|
|
408
|
+
const packet = request.executor_packet;
|
|
409
|
+
const lease = request.staged_lease;
|
|
410
|
+
if (lease.run_id !== bootstrap.run_id || lease.todo_id !== packet.todo_id
|
|
411
|
+
|| lease.plan_epoch !== packet.plan_epoch || lease.packet_digest !== packet.packet_digest
|
|
412
|
+
|| lease.controller_registration_digest !== request.registration_digest
|
|
413
|
+
|| lease.supervisor_session_nonce_digest !== sessionNonceDigest) {
|
|
414
|
+
fail('SCRIPTED_LEASE_REJECTED', 'prepare packetとstaged leaseのbindingが不正');
|
|
415
|
+
}
|
|
416
|
+
const prior = stagedLeases.get(lease.lease_digest);
|
|
417
|
+
if (prior !== undefined
|
|
418
|
+
&& canonicalizeArtifact(prior) !== canonicalizeArtifact(lease)) {
|
|
419
|
+
fail('SCRIPTED_LEASE_REJECTED', '同じdigestのstaged lease bytesが変化した');
|
|
420
|
+
}
|
|
421
|
+
stagedLeases.set(lease.lease_digest, structuredClone(lease));
|
|
422
|
+
preparedPackets.set(packet.packet_digest, structuredClone(packet));
|
|
423
|
+
currentEpoch = packet.plan_epoch;
|
|
424
|
+
const ack = sign({
|
|
425
|
+
schema: 'lattice.adapter_prepare_ack.v1',
|
|
426
|
+
ack_id: `prepare-${packet.packet_digest.slice(0, 24)}`,
|
|
427
|
+
registration_digest: request.registration_digest,
|
|
428
|
+
controller_id: controllerId,
|
|
429
|
+
run_id: bootstrap.run_id,
|
|
430
|
+
todo_id: packet.todo_id,
|
|
431
|
+
plan_epoch: packet.plan_epoch,
|
|
432
|
+
packet_digest: packet.packet_digest,
|
|
433
|
+
staged_lease_digest: lease.lease_digest,
|
|
434
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
435
|
+
ack_digest: '',
|
|
436
|
+
}, 'ack_digest');
|
|
437
|
+
return sign({
|
|
438
|
+
schema: 'lattice.adapter_prepare_response.v1',
|
|
439
|
+
request_id: request.request_id,
|
|
440
|
+
prepare_ack: ack,
|
|
441
|
+
staged_lease_digest: lease.lease_digest,
|
|
442
|
+
response_digest: '',
|
|
443
|
+
}, 'response_digest');
|
|
444
|
+
}
|
|
445
|
+
if (operation === 'activate') {
|
|
446
|
+
if (request.staged_lease_digests.some((digest) => !stagedLeases.has(digest))) {
|
|
447
|
+
fail('SCRIPTED_LEASE_REJECTED', 'activateが未知のstaged leaseを含む');
|
|
448
|
+
}
|
|
449
|
+
const epochs = new Set(request.staged_lease_digests
|
|
450
|
+
.map((digest) => stagedLeases.get(digest).plan_epoch));
|
|
451
|
+
if (epochs.size > 1) fail('SCRIPTED_LEASE_REJECTED', 'activate leaseのepochが混在する');
|
|
452
|
+
if (epochs.size === 1) currentEpoch = [...epochs][0];
|
|
453
|
+
const ack = sign({
|
|
454
|
+
schema: 'lattice.adapter_ready_ack.v1',
|
|
455
|
+
ack_id: `ready-${request.activation_digest.slice(0, 24)}`,
|
|
456
|
+
registration_digest: request.registration_digest,
|
|
457
|
+
controller_id: controllerId,
|
|
458
|
+
run_id: bootstrap.run_id,
|
|
459
|
+
plan_epoch: currentEpoch,
|
|
460
|
+
activation_digest: request.activation_digest,
|
|
461
|
+
staged_lease_digests: [...request.staged_lease_digests],
|
|
462
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
463
|
+
ack_digest: '',
|
|
464
|
+
}, 'ack_digest');
|
|
465
|
+
return sign({
|
|
466
|
+
schema: 'lattice.adapter_activate_response.v1',
|
|
467
|
+
request_id: request.request_id,
|
|
468
|
+
ready_ack: ack,
|
|
469
|
+
observed_pointer_digest: request.committed_epoch_digest,
|
|
470
|
+
response_digest: '',
|
|
471
|
+
}, 'response_digest');
|
|
472
|
+
}
|
|
473
|
+
if (operation === 'release') {
|
|
474
|
+
const armed = [];
|
|
475
|
+
for (const stagedDigest of request.staged_lease_digests) {
|
|
476
|
+
const staged = stagedLeases.get(stagedDigest);
|
|
477
|
+
if (staged === undefined) {
|
|
478
|
+
fail('SCRIPTED_LEASE_REJECTED', 'releaseが未知のstaged leaseを含む');
|
|
479
|
+
}
|
|
480
|
+
const lease = armStagedWriteLease(staged, {
|
|
481
|
+
releaseBarrierDigest: request.release_barrier_digest,
|
|
482
|
+
gateGeneration: request.gate_generation,
|
|
483
|
+
});
|
|
484
|
+
stagedLeases.delete(stagedDigest);
|
|
485
|
+
armedLeases.set(lease.lease_digest, lease);
|
|
486
|
+
armed.push(lease.lease_digest);
|
|
487
|
+
}
|
|
488
|
+
armed.sort();
|
|
489
|
+
const ack = sign({
|
|
490
|
+
schema: 'lattice.adapter_release_ack.v1',
|
|
491
|
+
ack_id: `release-${request.release_barrier_digest.slice(0, 24)}`,
|
|
492
|
+
registration_digest: request.registration_digest,
|
|
493
|
+
controller_id: controllerId,
|
|
494
|
+
run_id: bootstrap.run_id,
|
|
495
|
+
plan_epoch: currentEpoch,
|
|
496
|
+
release_barrier_digest: request.release_barrier_digest,
|
|
497
|
+
gate_generation: request.gate_generation,
|
|
498
|
+
armed_lease_digests: armed,
|
|
499
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
500
|
+
ack_digest: '',
|
|
501
|
+
}, 'ack_digest');
|
|
502
|
+
return sign({
|
|
503
|
+
schema: 'lattice.adapter_release_response.v1',
|
|
504
|
+
request_id: request.request_id,
|
|
505
|
+
release_ack: ack,
|
|
506
|
+
armed_lease_digests: armed,
|
|
507
|
+
observed_gate_generation: request.gate_generation,
|
|
508
|
+
response_digest: '',
|
|
509
|
+
}, 'response_digest');
|
|
510
|
+
}
|
|
511
|
+
if (operation === 'dispatch') {
|
|
512
|
+
const { packet, write_lease: writeLease } = request;
|
|
513
|
+
if (!validateExecutorPacket(packet) || !validateArmedWriteLease(writeLease)
|
|
514
|
+
|| !armedLeases.has(writeLease.lease_digest)
|
|
515
|
+
|| writeLease.run_id !== bootstrap.run_id
|
|
516
|
+
|| writeLease.todo_id !== packet.todo_id
|
|
517
|
+
|| writeLease.plan_epoch !== packet.plan_epoch
|
|
518
|
+
|| writeLease.packet_digest !== packet.packet_digest
|
|
519
|
+
|| writeLease.controller_registration_digest !== request.registration_digest
|
|
520
|
+
|| writeLease.supervisor_session_nonce_digest !== sessionNonceDigest) {
|
|
521
|
+
fail('SCRIPTED_PACKET_REJECTED', 'dispatch packetとarmed leaseのbindingが不正');
|
|
522
|
+
}
|
|
523
|
+
const priorHandle = todoToHandle.get(packet.todo_id);
|
|
524
|
+
if (priorHandle !== undefined) {
|
|
525
|
+
const prior = tasks.get(priorHandle);
|
|
526
|
+
if (prior.packet.packet_digest !== packet.packet_digest
|
|
527
|
+
|| prior.lease.lease_digest !== writeLease.lease_digest) {
|
|
528
|
+
fail('SCRIPTED_DUPLICATE_DISPATCH', '同じTODOへ異なるpacketをdispatchできない');
|
|
529
|
+
}
|
|
530
|
+
return sign({
|
|
531
|
+
schema: 'lattice.adapter_dispatch_response.v1',
|
|
532
|
+
request_id: request.request_id,
|
|
533
|
+
executor_handle: prior.executorHandle,
|
|
534
|
+
worktree_id: prior.worktreeId,
|
|
535
|
+
packet_digest: packet.packet_digest,
|
|
536
|
+
lease_digest: writeLease.lease_digest,
|
|
537
|
+
response_digest: '',
|
|
538
|
+
}, 'response_digest');
|
|
539
|
+
}
|
|
540
|
+
await readAndValidateGate(canonicalRunDir, writeLease);
|
|
541
|
+
const { observedDiff, checkpointDigest } = await executePacket({
|
|
542
|
+
packet,
|
|
543
|
+
repoRoot: canonicalRepoRoot,
|
|
544
|
+
});
|
|
545
|
+
const executorHandle = `scripted-${packet.packet_digest.slice(0, 24)}`;
|
|
546
|
+
const worktreeId = `scripted-wt-${packet.packet_digest.slice(0, 24)}`;
|
|
547
|
+
const receipt = sign({
|
|
548
|
+
schema: 'lattice.executor_receipt.v1',
|
|
549
|
+
receipt_id: `receipt-${packet.packet_digest.slice(0, 24)}`,
|
|
550
|
+
executor_handle: executorHandle,
|
|
551
|
+
worktree_id: worktreeId,
|
|
552
|
+
base_sha: packet.base_sha,
|
|
553
|
+
plan_epoch: packet.plan_epoch,
|
|
554
|
+
packet_digest: packet.packet_digest,
|
|
555
|
+
todo_id: packet.todo_id,
|
|
556
|
+
checkpoint_digest: checkpointDigest,
|
|
557
|
+
observed_diff: observedDiff,
|
|
558
|
+
receipt_digest: '',
|
|
559
|
+
}, 'receipt_digest');
|
|
560
|
+
if (!validateExecutorReceipt(receipt)) {
|
|
561
|
+
fail('SCRIPTED_EXECUTION_FAILED', '生成receiptがexecutor contractを満たさない');
|
|
562
|
+
}
|
|
563
|
+
const payloadDigest = await persistReceipt(receipt);
|
|
564
|
+
const task = {
|
|
565
|
+
packet: structuredClone(packet),
|
|
566
|
+
lease: structuredClone(writeLease),
|
|
567
|
+
executorHandle,
|
|
568
|
+
worktreeId,
|
|
569
|
+
receipt,
|
|
570
|
+
payloadDigest,
|
|
571
|
+
state: 'terminal',
|
|
572
|
+
};
|
|
573
|
+
tasks.set(executorHandle, task);
|
|
574
|
+
todoToHandle.set(packet.todo_id, executorHandle);
|
|
575
|
+
return sign({
|
|
576
|
+
schema: 'lattice.adapter_dispatch_response.v1',
|
|
577
|
+
request_id: request.request_id,
|
|
578
|
+
executor_handle: executorHandle,
|
|
579
|
+
worktree_id: worktreeId,
|
|
580
|
+
packet_digest: packet.packet_digest,
|
|
581
|
+
lease_digest: writeLease.lease_digest,
|
|
582
|
+
response_digest: '',
|
|
583
|
+
}, 'response_digest');
|
|
584
|
+
}
|
|
585
|
+
if (operation === 'observe') {
|
|
586
|
+
const task = tasks.get(request.executor_handle);
|
|
587
|
+
if (task === undefined
|
|
588
|
+
|| task.packet.plan_epoch !== request.expected_epoch
|
|
589
|
+
|| task.lease.lease_digest !== request.expected_lease_digest) {
|
|
590
|
+
fail('SCRIPTED_OBSERVATION_REJECTED', 'observe bindingがdispatch記録と一致しない');
|
|
591
|
+
}
|
|
592
|
+
const observation = sign({
|
|
593
|
+
schema: 'lattice.adapter_observation.v1',
|
|
594
|
+
state: task.state,
|
|
595
|
+
executor_handle: task.executorHandle,
|
|
596
|
+
plan_epoch: task.packet.plan_epoch,
|
|
597
|
+
lease_digest: task.lease.lease_digest,
|
|
598
|
+
payload_digest: task.payloadDigest,
|
|
599
|
+
observation_digest: '',
|
|
600
|
+
}, 'observation_digest');
|
|
601
|
+
return sign({
|
|
602
|
+
schema: 'lattice.adapter_observe_response.v1',
|
|
603
|
+
request_id: request.request_id,
|
|
604
|
+
observation,
|
|
605
|
+
observation_digest: observation.observation_digest,
|
|
606
|
+
response_digest: '',
|
|
607
|
+
}, 'response_digest');
|
|
608
|
+
}
|
|
609
|
+
if (operation === 'inventory') {
|
|
610
|
+
const runningBindings = [...tasks.values()]
|
|
611
|
+
.filter((task) => task.state === 'running')
|
|
612
|
+
.map((task) => ({
|
|
613
|
+
todo_id: task.packet.todo_id,
|
|
614
|
+
executor_handle: task.executorHandle,
|
|
615
|
+
worktree_id: task.worktreeId,
|
|
616
|
+
plan_epoch: task.packet.plan_epoch,
|
|
617
|
+
packet_digest: task.packet.packet_digest,
|
|
618
|
+
write_lease_id: task.lease.lease_id,
|
|
619
|
+
controller_registration_digest: request.registration_digest,
|
|
620
|
+
}))
|
|
621
|
+
.sort((left, right) => left.todo_id.localeCompare(right.todo_id));
|
|
622
|
+
return sign({
|
|
623
|
+
schema: 'lattice.adapter_running_inventory_response.v1',
|
|
624
|
+
request_id: request.request_id,
|
|
625
|
+
running_bindings: runningBindings,
|
|
626
|
+
inventory_digest: digestArtifact(runningBindings),
|
|
627
|
+
response_digest: '',
|
|
628
|
+
}, 'response_digest');
|
|
629
|
+
}
|
|
630
|
+
if (operation === 'barrier') {
|
|
631
|
+
const acks = request.running_bindings.map((binding) => {
|
|
632
|
+
const task = tasks.get(binding.executor_handle);
|
|
633
|
+
if (task === undefined || task.packet.todo_id !== binding.todo_id) {
|
|
634
|
+
fail('SCRIPTED_BARRIER_REJECTED', 'barrierが未知のrunning bindingを含む');
|
|
635
|
+
}
|
|
636
|
+
task.state = 'held';
|
|
637
|
+
const processObservationDigest = digestArtifact({
|
|
638
|
+
schema: 'lattice.scripted_process_observation.v1',
|
|
639
|
+
executor_handle: task.executorHandle,
|
|
640
|
+
state: 'stopped',
|
|
641
|
+
});
|
|
642
|
+
const worktreeFingerprintDigest = digestArtifact({
|
|
643
|
+
schema: 'lattice.scripted_worktree_fingerprint.v1',
|
|
644
|
+
worktree_id: task.worktreeId,
|
|
645
|
+
checkpoint_digest: task.receipt.checkpoint_digest,
|
|
646
|
+
});
|
|
647
|
+
return sign({
|
|
648
|
+
schema: 'lattice.executor_quiescence_ack.v1',
|
|
649
|
+
ack_id: `barrier-${binding.packet_digest.slice(0, 24)}`,
|
|
650
|
+
run_id: bootstrap.run_id,
|
|
651
|
+
todo_id: binding.todo_id,
|
|
652
|
+
executor_handle: binding.executor_handle,
|
|
653
|
+
worktree_id: binding.worktree_id,
|
|
654
|
+
plan_epoch: binding.plan_epoch,
|
|
655
|
+
packet_digest: binding.packet_digest,
|
|
656
|
+
write_lease_id: binding.write_lease_id,
|
|
657
|
+
barrier_control_digest: request.barrier_control_digest,
|
|
658
|
+
final_checkpoint_digest: task.receipt.checkpoint_digest,
|
|
659
|
+
process_observation_digest: processObservationDigest,
|
|
660
|
+
worktree_fingerprint_digest: worktreeFingerprintDigest,
|
|
661
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
662
|
+
ack_digest: '',
|
|
663
|
+
}, 'ack_digest');
|
|
664
|
+
});
|
|
665
|
+
return sign({
|
|
666
|
+
schema: 'lattice.adapter_barrier_response.v1',
|
|
667
|
+
request_id: request.request_id,
|
|
668
|
+
barrier_id: request.barrier_id,
|
|
669
|
+
quiescence_acks: acks,
|
|
670
|
+
response_digest: '',
|
|
671
|
+
}, 'response_digest');
|
|
672
|
+
}
|
|
673
|
+
if (operation === 'rebind') {
|
|
674
|
+
const rebind = request.rebind_packet;
|
|
675
|
+
const task = tasks.get(rebind.executor_handle);
|
|
676
|
+
if (task === undefined || task.worktreeId !== rebind.worktree_id
|
|
677
|
+
|| task.packet.todo_id !== rebind.todo_id) {
|
|
678
|
+
fail('SCRIPTED_REBIND_REJECTED', 'rebind packetが既存taskへ帰属しない');
|
|
679
|
+
}
|
|
680
|
+
const staged = request.staged_lease;
|
|
681
|
+
if (staged.todo_id !== rebind.todo_id || staged.plan_epoch !== rebind.new_plan_epoch
|
|
682
|
+
|| staged.packet_digest !== rebind.packet_digest) {
|
|
683
|
+
fail('SCRIPTED_REBIND_REJECTED', 'rebind packetとstaged leaseが一致しない');
|
|
684
|
+
}
|
|
685
|
+
stagedLeases.set(staged.lease_digest, structuredClone(staged));
|
|
686
|
+
task.packet.plan_epoch = rebind.new_plan_epoch;
|
|
687
|
+
currentEpoch = rebind.new_plan_epoch;
|
|
688
|
+
const ack = sign({
|
|
689
|
+
schema: 'lattice.executor_epoch_rebind_ack.v1',
|
|
690
|
+
ack_id: `rebind-${rebind.packet_digest.slice(0, 24)}`,
|
|
691
|
+
run_id: bootstrap.run_id,
|
|
692
|
+
todo_id: rebind.todo_id,
|
|
693
|
+
executor_handle: rebind.executor_handle,
|
|
694
|
+
worktree_id: rebind.worktree_id,
|
|
695
|
+
predecessor_epoch: rebind.new_plan_epoch - 1,
|
|
696
|
+
successor_epoch: rebind.new_plan_epoch,
|
|
697
|
+
predecessor_packet_digest: task.receipt.packet_digest,
|
|
698
|
+
rebind_packet_digest: rebind.packet_digest,
|
|
699
|
+
new_write_lease_id: staged.lease_id,
|
|
700
|
+
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
701
|
+
ack_digest: '',
|
|
702
|
+
}, 'ack_digest');
|
|
703
|
+
return sign({
|
|
704
|
+
schema: 'lattice.adapter_rebind_response.v1',
|
|
705
|
+
request_id: request.request_id,
|
|
706
|
+
rebind_ack: ack,
|
|
707
|
+
staged_lease_digest: staged.lease_digest,
|
|
708
|
+
response_digest: '',
|
|
709
|
+
}, 'response_digest');
|
|
710
|
+
}
|
|
711
|
+
if (operation === 'revoke') {
|
|
712
|
+
const available = new Set([...stagedLeases.keys(), ...armedLeases.keys()]);
|
|
713
|
+
if (request.lease_digests.some((digest) => !available.has(digest))) {
|
|
714
|
+
fail('SCRIPTED_REVOKE_REJECTED', 'revokeが未知のleaseを含む');
|
|
715
|
+
}
|
|
716
|
+
for (const leaseDigest of request.lease_digests) {
|
|
717
|
+
stagedLeases.delete(leaseDigest);
|
|
718
|
+
armedLeases.delete(leaseDigest);
|
|
719
|
+
}
|
|
720
|
+
for (const task of tasks.values()) {
|
|
721
|
+
if (request.lease_digests.includes(task.lease.lease_digest)) task.state = 'held';
|
|
722
|
+
}
|
|
723
|
+
return sign({
|
|
724
|
+
schema: 'lattice.adapter_revoke_response.v1',
|
|
725
|
+
request_id: request.request_id,
|
|
726
|
+
revoked_lease_digests: [...request.lease_digests],
|
|
727
|
+
residual_processes: [],
|
|
728
|
+
response_digest: '',
|
|
729
|
+
}, 'response_digest');
|
|
730
|
+
}
|
|
731
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のcontroller operation: ${operation}`);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
return Object.freeze({
|
|
735
|
+
bootstrap: structuredClone(bootstrap),
|
|
736
|
+
controllerId,
|
|
737
|
+
controllerSessionNonce,
|
|
738
|
+
descriptor: structuredClone(descriptor),
|
|
739
|
+
heartbeat,
|
|
740
|
+
leaseSetDigest() {
|
|
741
|
+
return digestArtifact([...stagedLeases.keys(), ...armedLeases.keys()].sort());
|
|
742
|
+
},
|
|
743
|
+
handshake(request) {
|
|
744
|
+
if (!validateHandshakeRequest(request, bootstrap)) {
|
|
745
|
+
fail('SCRIPTED_HANDSHAKE_INVALID', 'handshake requestがbootstrapへbindしない');
|
|
746
|
+
}
|
|
747
|
+
return sign({
|
|
748
|
+
schema: 'lattice.adapter_controller_handshake_response.v1',
|
|
749
|
+
request_id: request.request_id,
|
|
750
|
+
run_id: bootstrap.run_id,
|
|
751
|
+
challenge_digest: digestArtifact(request.challenge),
|
|
752
|
+
controller_session_nonce: controllerSessionNonce,
|
|
753
|
+
descriptor,
|
|
754
|
+
response_digest: '',
|
|
755
|
+
}, 'response_digest');
|
|
756
|
+
},
|
|
757
|
+
async request(operation, request) {
|
|
758
|
+
if (!CONTROLLER_OPERATIONS.includes(operation)) {
|
|
759
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のcontroller operation: ${operation}`);
|
|
760
|
+
}
|
|
761
|
+
return responseFor(operation, request);
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function readBootstrapFd(fd = 3) {
|
|
767
|
+
const bytes = readFileSync(fd);
|
|
768
|
+
if (bytes.length === 0 || bytes.length > MAX_DOCUMENT_BYTES) {
|
|
769
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrap sizeが不正');
|
|
770
|
+
}
|
|
771
|
+
const text = bytes.toString('utf8');
|
|
772
|
+
if (!text.endsWith('\n') || text.indexOf('\n') !== text.length - 1) {
|
|
773
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrapはJSON 1 documentでなければならない');
|
|
774
|
+
}
|
|
775
|
+
let value;
|
|
776
|
+
try {
|
|
777
|
+
value = JSON.parse(text);
|
|
778
|
+
} catch {
|
|
779
|
+
fail('SCRIPTED_BOOTSTRAP_INVALID', 'bootstrap JSONが不正');
|
|
780
|
+
}
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function runScriptedAdapterController({
|
|
785
|
+
runDir = process.cwd(),
|
|
786
|
+
bootstrap = null,
|
|
787
|
+
stderr = process.stderr,
|
|
788
|
+
} = {}) {
|
|
789
|
+
const resolvedBootstrap = bootstrap ?? await readBootstrapFd();
|
|
790
|
+
const canonicalRunDir = await realpath(runDir);
|
|
791
|
+
const repoRoot = await realpath(path.resolve(canonicalRunDir, '..', '..', '..'));
|
|
792
|
+
const controller = await createScriptedAdapterController({
|
|
793
|
+
bootstrap: resolvedBootstrap,
|
|
794
|
+
runDir: canonicalRunDir,
|
|
795
|
+
repoRoot,
|
|
796
|
+
});
|
|
797
|
+
const socketPath = path.join(canonicalRunDir, resolvedBootstrap.controller_socket_ref);
|
|
798
|
+
const controllerDirectory = path.dirname(socketPath);
|
|
799
|
+
await mkdir(controllerDirectory, { recursive: true, mode: 0o700 });
|
|
800
|
+
await chmod(controllerDirectory, 0o700);
|
|
801
|
+
let heartbeatTimer = null;
|
|
802
|
+
let persistentSocket = null;
|
|
803
|
+
let heartbeatSequence = 0;
|
|
804
|
+
let registrationDigest = null;
|
|
805
|
+
const send = (socket, document) => {
|
|
806
|
+
socket.write(`${canonicalizeArtifact(document)}\n`);
|
|
807
|
+
};
|
|
808
|
+
const startHeartbeat = (socket, digest) => {
|
|
809
|
+
persistentSocket = socket;
|
|
810
|
+
registrationDigest = digest;
|
|
811
|
+
if (heartbeatTimer !== null) return;
|
|
812
|
+
heartbeatTimer = setInterval(() => {
|
|
813
|
+
if (socket.destroyed) return;
|
|
814
|
+
heartbeatSequence += 1;
|
|
815
|
+
send(socket, sign({
|
|
816
|
+
schema: 'lattice.adapter_controller_heartbeat.v1',
|
|
817
|
+
controller_id: controller.controllerId,
|
|
818
|
+
registration_digest: registrationDigest,
|
|
819
|
+
supervisor_session_nonce: resolvedBootstrap.supervisor_session_nonce,
|
|
820
|
+
sequence: heartbeatSequence,
|
|
821
|
+
lease_set_digest: controller.leaseSetDigest(),
|
|
822
|
+
heartbeat_digest: '',
|
|
823
|
+
}, 'heartbeat_digest'));
|
|
824
|
+
}, controller.heartbeat.interval_ms);
|
|
825
|
+
heartbeatTimer.unref();
|
|
826
|
+
};
|
|
827
|
+
const server = net.createServer((socket) => {
|
|
828
|
+
socket.setEncoding('utf8');
|
|
829
|
+
let buffer = '';
|
|
830
|
+
let processing = Promise.resolve();
|
|
831
|
+
socket.on('data', (chunk) => {
|
|
832
|
+
buffer += chunk;
|
|
833
|
+
if (Buffer.byteLength(buffer, 'utf8') > MAX_DOCUMENT_BYTES) {
|
|
834
|
+
send(socket, controllerErrorArtifact(
|
|
835
|
+
new ScriptedAdapterControllerError(
|
|
836
|
+
'SCRIPTED_DOCUMENT_TOO_LARGE',
|
|
837
|
+
'controller document上限を超えた',
|
|
838
|
+
),
|
|
839
|
+
));
|
|
840
|
+
socket.destroy();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
while (buffer.includes('\n')) {
|
|
844
|
+
const newline = buffer.indexOf('\n');
|
|
845
|
+
const line = buffer.slice(0, newline);
|
|
846
|
+
buffer = buffer.slice(newline + 1);
|
|
847
|
+
processing = processing.then(async () => {
|
|
848
|
+
let request;
|
|
849
|
+
try {
|
|
850
|
+
request = JSON.parse(line);
|
|
851
|
+
if (request.schema === 'lattice.adapter_controller_handshake_request.v1') {
|
|
852
|
+
send(socket, controller.handshake(request));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const operation = REQUEST_SCHEMA_TO_OPERATION[request.schema];
|
|
856
|
+
if (operation === undefined) {
|
|
857
|
+
fail('SCRIPTED_UNKNOWN_OPERATION', `未知のrequest schema: ${String(request.schema)}`);
|
|
858
|
+
}
|
|
859
|
+
const response = await controller.request(operation, request);
|
|
860
|
+
startHeartbeat(socket, request.registration_digest);
|
|
861
|
+
send(socket, response);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
send(socket, controllerErrorArtifact(error, request?.request_id));
|
|
864
|
+
stderr.write(`${canonicalizeArtifact(controllerErrorArtifact(error, request?.request_id))}\n`);
|
|
865
|
+
socket.destroy();
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
socket.on('close', () => {
|
|
871
|
+
if (socket === persistentSocket) {
|
|
872
|
+
clearInterval(heartbeatTimer);
|
|
873
|
+
heartbeatTimer = null;
|
|
874
|
+
persistentSocket = null;
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
server.on('error', (error) => {
|
|
879
|
+
stderr.write(`${canonicalizeArtifact(controllerErrorArtifact(error))}\n`);
|
|
880
|
+
});
|
|
881
|
+
await new Promise((resolve, reject) => {
|
|
882
|
+
server.once('error', reject);
|
|
883
|
+
// AF_UNIXのpath長上限へ一時repoのabsolute prefixを混入させない。supervisorも
|
|
884
|
+
// run storeをcwdにして同じ固定relative refへ接続する。
|
|
885
|
+
server.listen(resolvedBootstrap.controller_socket_ref, () => {
|
|
886
|
+
server.off('error', reject);
|
|
887
|
+
resolve();
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
await chmod(socketPath, 0o600);
|
|
891
|
+
let closed = false;
|
|
892
|
+
const close = async () => {
|
|
893
|
+
if (closed) return;
|
|
894
|
+
closed = true;
|
|
895
|
+
clearInterval(heartbeatTimer);
|
|
896
|
+
persistentSocket?.destroy();
|
|
897
|
+
await new Promise((resolve) => server.close(resolve));
|
|
898
|
+
await rm(socketPath, { force: true });
|
|
899
|
+
};
|
|
900
|
+
return Object.freeze({ controller, socketPath, close });
|
|
901
|
+
}
|