@githolon/dsl 0.113.4 → 0.114.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/assets/operation-contract.wasm +0 -0
  2. package/compile_package.mjs +23 -3
  3. package/compile_proof.d.mts +7 -0
  4. package/compile_proof.mjs +38 -3
  5. package/dart/CHANGELOG.md +20 -0
  6. package/dart/lib/src/application_framework_calls.dart +9 -5
  7. package/dart/lib/src/generated_call.dart +14 -3
  8. package/dart/lib/src/nms_generated_runtime.dart +7 -0
  9. package/dart/lib/src/node_runner_transport.dart +77 -8
  10. package/dart/lib/src/nomos_trace.dart +107 -0
  11. package/dart/lib/src/reception/dart_reception.dart +148 -11
  12. package/dart/lib/src/rows_watch.dart +390 -0
  13. package/dart/lib/src/runner/node-entry.mjs +2803 -690
  14. package/dart/lib/src/sync_status_projection.dart +94 -1
  15. package/dart/lib/src/sync_status_reducer.dart +5 -2
  16. package/dart/lib/src/test_harness.dart +107 -41
  17. package/dart/lib/src/testing/test_peer.dart +107 -99
  18. package/dart/lib/src/testing/test_peer_host.dart +305 -0
  19. package/dart/lib/src/typed_watch_registry.dart +26 -3
  20. package/dart/pubspec.lock +1 -1
  21. package/dart/pubspec.yaml +2 -2
  22. package/dart/test/background_model_adoption_test.dart +1 -0
  23. package/dart/test/custodian_warming_test.dart +105 -0
  24. package/dart/test/fixtures/offer_watch_order_peer.dart +74 -0
  25. package/dart/test/kernel_author_after_read_test.dart +6 -3
  26. package/dart/test/kernel_fence_test.dart +4 -1
  27. package/dart/test/model_adoption_test.dart +17 -0
  28. package/dart/test/nomos_trace_test.dart +91 -0
  29. package/dart/test/own_offer_watch_order_test.dart +90 -0
  30. package/dart/test/receiving_group_open_test.dart +4 -8
  31. package/dart/test/replica_readiness_test.dart +14 -4
  32. package/dart/test/rows_watch_test.dart +331 -0
  33. package/dart/test/test_peer_host_selection_test.dart +49 -0
  34. package/dart_types/CHANGELOG.md +15 -0
  35. package/dart_types/lib/src/approved_model.dart +22 -5
  36. package/dart_types/lib/src/proto/nomos/kernel/v1/kernel.pb.dart +535 -1
  37. package/dart_types/lib/src/proto/nomos/kernel/v1/kernel.pbenum.dart +65 -0
  38. package/dart_types/lib/src/proto/nomos/runtime/v2/application_desk.pb.dart +14 -0
  39. package/dart_types/lib/src/proto/nomos/runtime/v2/correspondence_desk.pb.dart +359 -2
  40. package/dart_types/lib/src/proto/nomos/runtime/v2/correspondence_desk.pbenum.dart +65 -0
  41. package/dart_types/lib/src/proto/nomos/runtime/v2/model_adoption_desk.pb.dart +20 -2
  42. package/dart_types/lib/src/proto/nomos/runtime/v2/observability_desk.pb.dart +278 -9
  43. package/dart_types/lib/src/proto/nomos/runtime/v2/receiving_group_desk.pb.dart +7 -85
  44. package/dart_types/lib/src/proto/nomos/runtime/v2/residency_desk.pb.dart +35 -0
  45. package/dart_types/pubspec.yaml +1 -1
  46. package/package.json +4 -2
  47. package/src/build_provenance.ts +7 -2
  48. package/src/codegen_dart_client.ts +21 -9
  49. package/src/codegen_dart_facade.ts +1 -0
  50. package/src/codegen_dart_framework_calls.ts +12 -9
  51. package/src/codegen_dart_scenario.ts +7 -0
  52. package/src/codegen_ts.ts +26 -24
  53. package/src/compile_package_main.ts +65 -61
  54. package/src/engine_entry.ts +4 -48
  55. package/src/law_unit_set_identity.ts +44 -0
  56. package/src/query/compile.ts +0 -0
  57. package/src/release_inspection.ts +24 -0
  58. package/src/release_publication.ts +298 -0
@@ -46,6 +46,73 @@ enum SyncPhase {
46
46
  error
47
47
  }
48
48
 
49
+ /// The custodian is opening a workspace from verified custody — warming up after a restart,
50
+ /// a cold container or a cloud roll. It is not an error: nothing was refused, nothing is lost,
51
+ /// and the peer asks again at the custodian's own hint. A follow that carries this posture is
52
+ /// "waking up", never "needs attention", until [isStalled] says the wait has run well past what
53
+ /// the custodian promised.
54
+ final class CustodianWarmingPosture {
55
+ const CustodianWarmingPosture({
56
+ required this.since,
57
+ required this.expectedReadyAt,
58
+ required this.stalledAfter,
59
+ this.stalled = false,
60
+ });
61
+
62
+ factory CustodianWarmingPosture.fromRuntime(
63
+ nomos_protocol.CustodianWarming warming,
64
+ ) =>
65
+ CustodianWarmingPosture(
66
+ since: DateTime.fromMillisecondsSinceEpoch(
67
+ warming.sinceUnixMillis.toInt()),
68
+ expectedReadyAt: DateTime.fromMillisecondsSinceEpoch(
69
+ warming.expectedReadyUnixMillis.toInt()),
70
+ stalledAfter: DateTime.fromMillisecondsSinceEpoch(
71
+ warming.stalledAfterUnixMillis.toInt()),
72
+ stalled: warming.stalled,
73
+ );
74
+
75
+ /// When this peer first met the custodian's opening answer in this warming spell.
76
+ final DateTime since;
77
+
78
+ /// When the custodian said it expects to be ready (its latest retry hint).
79
+ final DateTime expectedReadyAt;
80
+
81
+ /// After this moment the warming has run well past the promise and counts as stalled.
82
+ final DateTime stalledAfter;
83
+
84
+ /// The peer had already judged the warming stalled when it reported it.
85
+ final bool stalled;
86
+
87
+ /// Stalled warming should escalate exactly like any other attention state. Judged against
88
+ /// [now] as well as the peer's report, so a quiet report still escalates on time.
89
+ bool isStalled(DateTime now) => stalled || !now.isBefore(stalledAfter);
90
+
91
+ /// How long the custodian has been warming up.
92
+ Duration elapsed(DateTime now) {
93
+ final value = now.difference(since);
94
+ return value.isNegative ? Duration.zero : value;
95
+ }
96
+
97
+ /// How long until the custodian expects to be ready; zero once that time has passed.
98
+ Duration remaining(DateTime now) {
99
+ final value = expectedReadyAt.difference(now);
100
+ return value.isNegative ? Duration.zero : value;
101
+ }
102
+
103
+ @override
104
+ bool operator ==(Object other) =>
105
+ other is CustodianWarmingPosture &&
106
+ other.since == since &&
107
+ other.expectedReadyAt == expectedReadyAt &&
108
+ other.stalledAfter == stalledAfter &&
109
+ other.stalled == stalled;
110
+
111
+ @override
112
+ int get hashCode =>
113
+ Object.hash(since, expectedReadyAt, stalledAfter, stalled);
114
+ }
115
+
49
116
  /// The durable Replica Follow's own state, observed without starting a round.
50
117
  enum WorkspaceFollowPhase {
51
118
  desired,
@@ -74,6 +141,7 @@ final class WorkspaceFollowStatus {
74
141
  this.activeEffect,
75
142
  this.unsent,
76
143
  this.unacknowledged,
144
+ this.custodianWarming,
77
145
  });
78
146
 
79
147
  factory WorkspaceFollowStatus.fromRuntime(
@@ -131,6 +199,9 @@ final class WorkspaceFollowStatus {
131
199
  unsent: report.hasUnsent() ? report.unsent : null,
132
200
  unacknowledged:
133
201
  report.hasUnacknowledged() ? report.unacknowledged : null,
202
+ custodianWarming: report.hasCustodianWarming()
203
+ ? CustodianWarmingPosture.fromRuntime(report.custodianWarming)
204
+ : null,
134
205
  );
135
206
 
136
207
  final String workspace;
@@ -146,7 +217,20 @@ final class WorkspaceFollowStatus {
146
217
  /// whose admission has not yet come back. Null when the live link is not streaming.
147
218
  final int? unsent;
148
219
  final int? unacknowledged;
220
+
221
+ /// Present while the custodian is opening this workspace from verified custody and that is
222
+ /// the only reason this follow is not yet current. Null for every ordinary error retry.
223
+ final CustodianWarmingPosture? custodianWarming;
224
+
149
225
  bool get current => phase == WorkspaceFollowPhase.settled && live;
226
+
227
+ /// The custodian is warming up and has not yet overrun its promise: calm, not an error.
228
+ bool isWarming(DateTime now) =>
229
+ !current && custodianWarming != null && !custodianWarming!.isStalled(now);
230
+
231
+ /// The custodian has been warming well past what it promised: escalate as attention.
232
+ bool isWarmingStalled(DateTime now) =>
233
+ !current && (custodianWarming?.isStalled(now) ?? false);
150
234
  }
151
235
 
152
236
  /// Disposable UI projection of typed peer-runtime sync observations.
@@ -192,6 +276,14 @@ final class SyncStatus {
192
276
  /// The peer's time for its most recent changed follow report.
193
277
  final DateTime? followReportAt;
194
278
 
279
+ /// Follows whose custodian is warming up within its promise: calm, not an error.
280
+ Iterable<WorkspaceFollowStatus> warmingFollows(DateTime now) =>
281
+ followStatuses.where((follow) => follow.isWarming(now));
282
+
283
+ /// Follows whose custodian has been warming well past its promise: attention.
284
+ Iterable<WorkspaceFollowStatus> stalledWarmingFollows(DateTime now) =>
285
+ followStatuses.where((follow) => follow.isWarmingStalled(now));
286
+
195
287
  /// Counts of Peer Office preparation observations, not inferred from sync.
196
288
  final int preparingWorkspaces;
197
289
  final int retryingWorkspaces;
@@ -298,7 +390,8 @@ bool _sameFollows(
298
390
  a.attempt != b.attempt ||
299
391
  a.activeEffect != b.activeEffect ||
300
392
  a.unsent != b.unsent ||
301
- a.unacknowledged != b.unacknowledged) return false;
393
+ a.unacknowledged != b.unacknowledged ||
394
+ a.custodianWarming != b.custodianWarming) return false;
302
395
  }
303
396
  return true;
304
397
  }
@@ -42,8 +42,11 @@ SyncStatus reduceSyncCompletion(
42
42
  }
43
43
  final result = completed.result;
44
44
  final receiptIssue = _receiptIssue(result.offerReceipt);
45
- final pending =
46
- result.stillAhead || result.directOfferDeferred || result.deferred > 0;
45
+ // A custodian warming up judged nothing: the round is still owed, never settled or failed.
46
+ final pending = result.stillAhead ||
47
+ result.directOfferDeferred ||
48
+ result.deferred > 0 ||
49
+ result.hasCustodianWarmingRetryAfterMillis();
47
50
  final awaitingRemoteDurability = result.stillAhead &&
48
51
  !result.directOfferDeferred &&
49
52
  !result.reconnectRequired &&
@@ -53,7 +53,10 @@ import 'runtime_v2_transport.dart';
53
53
  import 'session_bound.dart' show DomainNotInstalledForSession;
54
54
  import 'reception/dart_reception.dart'
55
55
  show DartReception, NomosBoundInteraction, NomosSession;
56
+ import 'reception/peer_office_connection.dart' show PeerOfficeConnection;
56
57
  import 'testing/test_peer.dart' show NmsTestPeer;
58
+ import 'testing/test_peer_host.dart'
59
+ show NmsTestPeerHostKind, nmsTestPeerHostVariable;
57
60
 
58
61
  enum _KernelRuntimeArtifact {
59
62
  executable('holon.wasm'),
@@ -325,7 +328,9 @@ class NomosTestHarness {
325
328
  /// The local workspace name the law was installed into.
326
329
  final String workspace;
327
330
 
328
- NodeRunnerTransport _transport;
331
+ /// The Peer Office connection under [reception]: the Node runner, or the Apple native
332
+ /// host when the local test peer runs there (`NOMOS_TEST_PEER_HOST=native`).
333
+ PeerOfficeConnection _transport;
329
334
  final Directory _workdir;
330
335
  final void Function(String line)? _onDiagnostic;
331
336
  bool _disposed = false;
@@ -541,14 +546,15 @@ class NomosTestHarness {
541
546
  'no lawUsdzPaths — pass the build/<name>.law.usdz `npx githolon compile` emits',
542
547
  );
543
548
  }
544
- final entry = nodeEntry ??
545
- await _resolveTestEntry(node, resolveFrom ?? Directory.current.path);
546
- final effectiveRuntimeDir = runtimeDir ??
547
- Platform.environment['NOMOS_CANDIDATE_RUNTIME'] ??
548
- Platform.environment['NOMOS_DART_RUNTIME'] ??
549
- Platform.environment['NOMOS_OFFLINE_RUNTIME'] ??
550
- await _sourceCandidateRuntime(entry) ??
551
- '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
549
+ final hostKind = NmsTestPeerHostKind.selected();
550
+ final (:entry, :runtimeDirectory) = await _localPeerInputs(
551
+ hostKind: hostKind,
552
+ nodeEntry: nodeEntry,
553
+ runtimeDir: runtimeDir,
554
+ node: node,
555
+ resolveFrom: resolveFrom,
556
+ );
557
+ final effectiveRuntimeDir = runtimeDirectory;
552
558
  final owner = ownerSubject?.trim();
553
559
  // THE CALLER DECLARES ITS LANE. Naming an owner IS the application-home lane;
554
560
  // the harness no longer re-derives a receiving-group name to recognise one by
@@ -574,18 +580,19 @@ class NomosTestHarness {
574
580
  ? 'nms-app-${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'
575
581
  : (workspace ?? 'test-ws');
576
582
 
577
- Directory? workdir;
578
- NodeRunnerTransport? transport;
583
+ NmsTestPeer? startedPeer;
579
584
  final phase = (String m) {
580
585
  if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m');
581
586
  };
587
+ phase('test peer host: ${hostKind.name} ($nmsTestPeerHostVariable)');
582
588
  try {
583
- final peer = await _phased(
589
+ final NmsTestPeer peer = await _phased(
584
590
  phase: '1/2: open canonical test peer',
585
591
  awaiting: 'Peer Office + filesystem Git custody',
586
592
  timeout: phaseTimeout ?? _kSpawnDeadline,
587
593
  onDiagnostic: onDiagnostic,
588
594
  run: () => NmsTestPeer.start(
595
+ kind: hostKind,
589
596
  entry: entry,
590
597
  runtimeDirectory: effectiveRuntimeDir,
591
598
  resolveFrom: resolveFrom,
@@ -593,25 +600,26 @@ class NomosTestHarness {
593
600
  onDiagnostic: onDiagnostic,
594
601
  ),
595
602
  );
596
- transport = peer.transport;
597
- workdir = peer.custody.directory;
603
+ startedPeer = peer;
604
+ final workdir = peer.custodyDirectory;
598
605
  var plane = peer.reception;
599
606
  // An application home is warranted by certificates the founding device signs, so that key is
600
- // minted FIRST and the peer is booted to trust it (typed boot configuration) before genesis.
607
+ // minted FIRST and the peer is made to trust it (typed boot configuration) before genesis.
601
608
  protocol.SigningKeyPair? founder;
602
609
  if (birthsApplicationHome) {
603
610
  founder = await _phased(
604
- phase: '1b/2: mint the founding device and boot the peer to trust it',
605
- awaiting: 'authorKeygen + peer reboot with --trust-root-public-key',
611
+ phase: '1b/2: mint the founding device and make the peer trust it',
612
+ awaiting: hostKind == NmsTestPeerHostKind.node
613
+ ? 'authorKeygen + peer reboot with --trust-root-public-key'
614
+ : 'authorKeygen + resident kernel trust-root configuration',
606
615
  timeout: phaseTimeout ?? _kSpawnDeadline,
607
616
  onDiagnostic: onDiagnostic,
608
617
  run: () async {
609
618
  final key = await peer.mintFoundingDevice();
610
- await peer.reboot(trustRootPublicKey: key.publicKey);
619
+ await peer.trustFoundingRoot(key.publicKey);
611
620
  return key;
612
621
  },
613
622
  );
614
- transport = peer.transport;
615
623
  plane = peer.reception;
616
624
  }
617
625
  // THE OFFICE NAMES THE APPLICATION HOME. The harness holds no copy of the
@@ -1008,7 +1016,7 @@ class NomosTestHarness {
1008
1016
  final harness = NomosTestHarness._(
1009
1017
  plane.session(openWorkspace),
1010
1018
  openWorkspace,
1011
- transport,
1019
+ peer.transport,
1012
1020
  workdir,
1013
1021
  onDiagnostic,
1014
1022
  [openWorkspace],
@@ -1042,18 +1050,46 @@ class NomosTestHarness {
1042
1050
  return harness;
1043
1051
  } catch (_) {
1044
1052
  // Setup failed — tear down deterministically so nothing is left running or on disk.
1045
- if (transport != null) {
1046
- try {
1047
- await transport.dispose();
1048
- } catch (_) {}
1049
- }
1050
1053
  try {
1051
- await workdir?.delete(recursive: true);
1054
+ await startedPeer?.dispose();
1052
1055
  } catch (_) {}
1053
1056
  rethrow;
1054
1057
  }
1055
1058
  }
1056
1059
 
1060
+ /// The local test peer's inputs for [hostKind]. The Node host needs its runner entry (and may
1061
+ /// take the source checkout's coordinated candidate runtime from it); the native host runs no
1062
+ /// Node, so no entry is resolved for it. Both read the framework controller law from the
1063
+ /// runtime directory.
1064
+ static Future<({String? entry, String runtimeDirectory})> _localPeerInputs({
1065
+ required NmsTestPeerHostKind hostKind,
1066
+ required String? nodeEntry,
1067
+ required String? runtimeDir,
1068
+ required String node,
1069
+ required String? resolveFrom,
1070
+ }) async {
1071
+ final explicitRuntime = runtimeDir ??
1072
+ Platform.environment['NOMOS_CANDIDATE_RUNTIME'] ??
1073
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
1074
+ Platform.environment['NOMOS_OFFLINE_RUNTIME'];
1075
+ final defaultRuntime =
1076
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
1077
+ switch (hostKind) {
1078
+ case NmsTestPeerHostKind.node:
1079
+ final entry = nodeEntry ??
1080
+ await _resolveTestEntry(
1081
+ node, resolveFrom ?? Directory.current.path);
1082
+ return (
1083
+ entry: entry,
1084
+ runtimeDirectory: explicitRuntime ??
1085
+ await _sourceCandidateRuntime(entry) ??
1086
+ defaultRuntime,
1087
+ );
1088
+ case NmsTestPeerHostKind.native:
1089
+ return (entry: null, runtimeDirectory: explicitRuntime ?? defaultRuntime);
1090
+ }
1091
+ }
1092
+
1057
1093
  /// A COLOCATED TEST CLUSTER — install MANY workspaces' law offline and connect one SESSION per workspace on
1058
1094
  /// ONE kernel host (one runner, one transport, one [reception]), so a cross-workspace scenario (attested
1059
1095
  /// reads, foreign-source imports) runs in-process with ZERO network. Each workspace has separate real Git
@@ -1101,14 +1137,18 @@ class NomosTestHarness {
1101
1137
  );
1102
1138
  }
1103
1139
  }
1104
- final entry = nodeEntry ??
1105
- await _resolveTestEntry(node, resolveFrom ?? Directory.current.path);
1106
- final effectiveRuntimeDir = runtimeDir ??
1107
- Platform.environment['NOMOS_CANDIDATE_RUNTIME'] ??
1108
- Platform.environment['NOMOS_DART_RUNTIME'] ??
1109
- Platform.environment['NOMOS_OFFLINE_RUNTIME'] ??
1110
- await _sourceCandidateRuntime(entry) ??
1111
- '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
1140
+ final hostKind = NmsTestPeerHostKind.selected();
1141
+ final (:entry, :runtimeDirectory) = await _localPeerInputs(
1142
+ hostKind: hostKind,
1143
+ nodeEntry: nodeEntry,
1144
+ runtimeDir: runtimeDir,
1145
+ node: node,
1146
+ resolveFrom: resolveFrom,
1147
+ );
1148
+ final effectiveRuntimeDir = runtimeDirectory;
1149
+ onDiagnostic?.call(
1150
+ '[nomos-test-harness] test peer host: ${hostKind.name} ($nmsTestPeerHostVariable)',
1151
+ );
1112
1152
  NmsTestPeer? peer;
1113
1153
  try {
1114
1154
  peer = await _phased(
@@ -1117,6 +1157,7 @@ class NomosTestHarness {
1117
1157
  timeout: phaseTimeout ?? _kSpawnDeadline,
1118
1158
  onDiagnostic: onDiagnostic,
1119
1159
  run: () => NmsTestPeer.start(
1160
+ kind: hostKind,
1120
1161
  entry: entry,
1121
1162
  runtimeDirectory: effectiveRuntimeDir,
1122
1163
  resolveFrom: resolveFrom,
@@ -1153,7 +1194,7 @@ class NomosTestHarness {
1153
1194
  peer!.reception,
1154
1195
  names.first,
1155
1196
  peer.transport,
1156
- peer.custody.directory,
1197
+ peer.custodyDirectory,
1157
1198
  onDiagnostic,
1158
1199
  names,
1159
1200
  devicePublics: devicePublics,
@@ -1230,6 +1271,14 @@ class NomosTestHarness {
1230
1271
  'NmsApplicationFrameworkCalls; raw framework directive transport is not supported',
1231
1272
  );
1232
1273
  }
1274
+ // The cloud-bound device is the Node peer talking to a test cloud; there is no native lane
1275
+ // for it. Selecting native here is refused, never quietly run on Node.
1276
+ if (NmsTestPeerHostKind.selected() != NmsTestPeerHostKind.node) {
1277
+ throw NomosTestHarnessException(
1278
+ 'cloudBound runs only on the Node test peer: unset $nmsTestPeerHostVariable '
1279
+ '(the native host serves the offline ephemeral and cluster harnesses)',
1280
+ );
1281
+ }
1233
1282
  final entry = nodeEntry ??
1234
1283
  await _resolveEntry(node, resolveFrom ?? Directory.current.path);
1235
1284
  final bareUid =
@@ -2069,8 +2118,7 @@ class NomosTestHarness {
2069
2118
  if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(fixture.workspace)) {
2070
2119
  throw StateError('Invalid fixture workspace');
2071
2120
  }
2072
- final repo =
2073
- '${peer.custody.directory.path}/custody/holon-${fixture.workspace}.git';
2121
+ final repo = peer.host.workspaceGitDirectory(fixture.workspace);
2074
2122
  // `update-ref <new> <old>` is a compare-and-swap, and git reads an
2075
2123
  // EMPTY <old> as "the ref must not exist". `prior` comes from the
2076
2124
  // plane's workspace status, which answers '' whenever the workspace
@@ -2170,7 +2218,7 @@ class NomosTestHarness {
2170
2218
  final targets = _applicationConnectedSessions();
2171
2219
  if (targets.remove(home)) targets.insert(0, home);
2172
2220
  await _plane.dispose();
2173
- await _transport.dispose();
2221
+ await _stopTransport();
2174
2222
 
2175
2223
  _transport = await NodeRunnerTransport.start(
2176
2224
  entry: lane.entry,
@@ -2245,13 +2293,13 @@ class NomosTestHarness {
2245
2293
  );
2246
2294
  }
2247
2295
  try {
2248
- await _transport.dispose().timeout(
2296
+ await _stopTransport().timeout(
2249
2297
  _phaseTimeout ?? _kOtherDeadline,
2250
2298
  onTimeout: () => throw HarnessPhaseTimeout(
2251
- phase: 'dispose: stop the node runner',
2299
+ phase: 'dispose: stop the peer host',
2252
2300
  workspace: workspace,
2253
2301
  elapsed: _phaseTimeout ?? _kOtherDeadline,
2254
- awaiting: 'NodeRunnerTransport.dispose',
2302
+ awaiting: 'test peer host stop',
2255
2303
  ),
2256
2304
  );
2257
2305
  } catch (e) {
@@ -2267,6 +2315,24 @@ class NomosTestHarness {
2267
2315
  if (f != null) f('[nomos-test-harness] phase dispose done');
2268
2316
  }
2269
2317
 
2318
+ /// Stop whatever hosts [_transport]: the local test peer's host when this harness owns one
2319
+ /// (its connection may have been replaced by a restart), otherwise the cloud-bound Node runner.
2320
+ Future<void> _stopTransport() async {
2321
+ final peer = _testPeer;
2322
+ if (peer != null && identical(peer.transport, _transport)) {
2323
+ await peer.host.stop();
2324
+ return;
2325
+ }
2326
+ final transport = _transport;
2327
+ if (transport is NodeRunnerTransport) {
2328
+ await transport.dispose();
2329
+ return;
2330
+ }
2331
+ throw StateError(
2332
+ 'the harness connection ${transport.runtimeType} has no owner to stop it',
2333
+ );
2334
+ }
2335
+
2270
2336
  void _diag(String line) {
2271
2337
  final f = _onDiagnostic;
2272
2338
  if (f != null) {