@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.24",
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
  }
@@ -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 });
@@ -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, { cwd: runDir, detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe'] });
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) fail('ADAPTER_CONTROLLER_UNAVAILABLE', `controller exited: ${child.exitCode}`);
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 { socketInfo = await lstat(handshakeSocket); } catch { fail('ADAPTER_CONTROLLER_UNAVAILABLE', 'controller socket未生成'); }
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 { supervisorDescriptor, activationControlEvent, controllerDescriptor, registration, sessionNonce: supervisorSessionNonce, childPid: child?.pid ?? controllerDescriptor.pid, createManagedSupervisor, registerWithManagedSupervisor, disposeController };
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
+ }