@githolon/dsl 0.73.0 → 0.74.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.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## 0.50.0
2
+
3
+ - **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
4
+ has a deadline (overridable via the new `phaseTimeout` param) and throws a typed
5
+ `HarnessPhaseTimeout` naming the phase (e.g. `"3b: keyed reconnect of 'catalogue'"`), workspace,
6
+ elapsed time, the awaited bridge op, and likely causes — a wedge is a 30-second named exception,
7
+ never a 40-minute bisection. Every phase logs terse enter/exit diagnostics. Disposal deadline
8
+ breaches are logged, not thrown — teardown always completes.
9
+ - **Keyed-reconnect lane hardened** (the `deviceKeyed` cluster hang report): harness phase 3b
10
+ (keygen + keyed reconnect) had no timeout and no diagnostics — any wedge there hung silently
11
+ after "4 session(s) connected". Additionally two runner/realm defects fixed: the stale session
12
+ record stayed routable during an async re-open (concurrent ops could hit the disposed holon —
13
+ now an atomic replace with a fast typed refusal in the window), and a re-open with new
14
+ credentials but no `restoreFrom` silently kept the OLD identity (the fresh authorSecret was
15
+ never installed) — an identity-bearing option change now forces an in-place re-mount over
16
+ resident custody.
17
+ - co2's acceptance implemented verbatim: `cluster(deviceKeyed:)` completes, `devicePublicKey`
18
+ populated, colocated attested read signed by the re-opened key, disposal completes.
19
+
1
20
  ## 0.49.0
2
21
 
3
22
  - **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
@@ -63,6 +63,43 @@ class NomosTestHarnessException implements Exception {
63
63
  String toString() => 'NomosTestHarnessException: $message';
64
64
  }
65
65
 
66
+ /// A TYPED per-phase deadline breach — the "NO SILENT HANGS" guarantee: every lifecycle await in
67
+ /// [NomosTestHarness] (install, runner spawn, ready, connect, keygen, keyed reconnect, seed, dispose)
68
+ /// runs under a generous deadline and, instead of wedging forever with no output, throws THIS —
69
+ /// naming the phase, the workspace, how long it waited, the bridge op it was awaiting, and the
70
+ /// likely causes where they are known. Subclasses [NomosTestHarnessException] so existing
71
+ /// `on NomosTestHarnessException` handling keeps working. Tune deadlines with the harness's
72
+ /// `phaseTimeout` parameter (it overrides EVERY phase's default when set).
73
+ class HarnessPhaseTimeout extends NomosTestHarnessException {
74
+ /// The human phase name (e.g. `3b: keyed reconnect of 'catalogue'`).
75
+ final String phase;
76
+
77
+ /// The workspace the phase was operating on (null for plane-wide phases like runner spawn).
78
+ final String? workspace;
79
+
80
+ /// How long the harness waited before giving up.
81
+ final Duration elapsed;
82
+
83
+ /// What the harness was awaiting (the bridge op / subprocess), e.g. `connect`, `authorKeygen`.
84
+ final String awaiting;
85
+
86
+ /// Likely causes, where the harness knows them (mirrors DomainNotInstalledForSession.possibleCauses).
87
+ final List<String> likelyCauses;
88
+
89
+ HarnessPhaseTimeout({
90
+ required this.phase,
91
+ this.workspace,
92
+ required this.elapsed,
93
+ required this.awaiting,
94
+ this.likelyCauses = const [],
95
+ }) : super(
96
+ 'phase "$phase"${workspace != null ? ' (workspace "$workspace")' : ''} '
97
+ 'did not complete within ${elapsed.inSeconds}s while awaiting `$awaiting`'
98
+ '${likelyCauses.isEmpty ? '' : ' — likely causes: ${likelyCauses.map((c) => '\n • $c').join()}'}');
99
+ @override
100
+ String toString() => 'HarnessPhaseTimeout: $message';
101
+ }
102
+
66
103
  /// The decoy session [NomosTestHarness.ephemeral] connects alongside the workspace under test — it exists
67
104
  /// so the plane is ALWAYS multi-session in tests (sole-session inference never silently passes).
68
105
  const String _decoyWorkspace = '__nomos-decoy';
@@ -107,17 +144,58 @@ class NomosTestHarness {
107
144
  /// The offline-install lane config (for [seedWorkspaceFromDeployJson]); null when unknown.
108
145
  final _InstallLane? _installLane;
109
146
 
147
+ /// When set, overrides EVERY phase's default deadline (see [HarnessPhaseTimeout]).
148
+ final Duration? _phaseTimeout;
149
+
110
150
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
111
151
  this._onDiagnostic, this._sessionNames,
112
152
  {Map<String, String> devicePublics = const {},
113
153
  this.frameworkHash,
114
154
  Map<String, List<String>> lawHashes = const {},
115
155
  Map<String, String> snapshots = const {},
116
- _InstallLane? installLane})
156
+ _InstallLane? installLane,
157
+ Duration? phaseTimeout})
117
158
  : _devicePublics = devicePublics,
118
159
  _lawHashes = Map.of(lawHashes),
119
160
  _snapshots = Map.of(snapshots),
120
- _installLane = installLane;
161
+ _installLane = installLane,
162
+ _phaseTimeout = phaseTimeout;
163
+
164
+ // ── THE "NO SILENT HANGS" MACHINERY — every lifecycle await runs as a named, deadlined PHASE ──
165
+
166
+ /// Per-phase default deadlines (a [phaseTimeout] argument overrides them all).
167
+ static const Duration _kInstallDeadline = Duration(seconds: 60);
168
+ static const Duration _kSpawnDeadline = Duration(seconds: 30);
169
+ static const Duration _kOtherDeadline = Duration(seconds: 30);
170
+
171
+ /// Run one lifecycle phase under a deadline, logging terse enter/exit lines (their ask 5) and
172
+ /// converting a breach into the typed [HarnessPhaseTimeout] — a wedge is NEVER a silent hang.
173
+ static Future<T> _phased<T>({
174
+ required String phase,
175
+ String? workspace,
176
+ required String awaiting,
177
+ required Duration timeout,
178
+ required Future<T> Function() run,
179
+ List<String> likelyCauses = const [],
180
+ void Function(String line)? onDiagnostic,
181
+ }) async {
182
+ void log(String m) {
183
+ if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m');
184
+ }
185
+
186
+ final where = workspace != null ? ' "$workspace"' : '';
187
+ log('phase $phase$where (≤${timeout.inSeconds}s) …');
188
+ final sw = Stopwatch()..start();
189
+ final r = await run().timeout(timeout,
190
+ onTimeout: () => throw HarnessPhaseTimeout(
191
+ phase: phase,
192
+ workspace: workspace,
193
+ elapsed: timeout,
194
+ awaiting: awaiting,
195
+ likelyCauses: likelyCauses));
196
+ log('phase $phase$where done (${sw.elapsedMilliseconds}ms)');
197
+ return r;
198
+ }
121
199
 
122
200
  /// The base64 device PUBLIC key a `deviceKeyed` [cluster] session was opened with (null otherwise). Its
123
201
  /// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
@@ -167,6 +245,7 @@ class NomosTestHarness {
167
245
  void Function(String line)? onDiagnostic,
168
246
  Duration readyTimeout = const Duration(seconds: 30),
169
247
  Duration connectTimeout = const Duration(seconds: 120),
248
+ Duration? phaseTimeout,
170
249
  bool decoySession = true,
171
250
  }) async {
172
251
  if (deployJsonPaths.isEmpty) {
@@ -196,48 +275,70 @@ class NomosTestHarness {
196
275
  final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
197
276
  try {
198
277
  final snapshot = '${workdir.path}/snapshot.bin';
199
- phase('phase 1/4 installing ${deployJsonPaths.length} law package(s) offline into "$workspace" …');
200
- final info = await _runInstall(
201
- node: node,
202
- useInRepoScript: useInRepoScript,
203
- inRepoScript: resolvedHelper,
204
- githolonCmd: githolonCmd,
205
- deployJsonPaths: deployJsonPaths,
278
+ final info = await _phased(
279
+ phase: '1/4: offline law install (${deployJsonPaths.length} package(s))',
206
280
  workspace: workspace,
207
- snapshot: snapshot,
208
- runtimeDir: runtimeDir,
209
- onDiagnostic: onDiagnostic);
210
-
211
- phase('phase 2/4 spawning the node runner (node=$node) …');
212
- transport = await NodeRunnerTransport.start(
213
- entry: entry,
214
- resolveFrom: resolveFrom,
215
- node: node,
216
- environment: {
217
- 'NOMOS_OFFLINE': '1',
218
- 'NOMOS_OFFLINE_RESTORE': snapshot,
219
- if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
220
- },
221
- onDiagnostic: onDiagnostic,
222
- );
223
- final plane = NomosBridge(transport);
224
- phase('phase 3/4 awaiting runner ready (≤${readyTimeout.inSeconds}s) …');
225
- await plane.ready.timeout(readyTimeout,
226
- onTimeout: () => throw NomosTestHarnessException(
227
- 'the offline runner never signalled ready within ${readyTimeout.inSeconds}s '
228
- '(phase 3/4: is `node` on PATH and ~/.holon/runtime warm? the runner process may have '
229
- 'failed to boot the engine — run with onDiagnostic to see its stderr)'));
230
- phase('phase 4/4 connecting session "$workspace" (≤${connectTimeout.inSeconds}s) …');
231
- await plane.connect(
232
- cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
233
- workspace: workspace,
234
- clientId: clientId,
235
- allowUnborn: true,
236
- ).timeout(connectTimeout,
237
- onTimeout: () => throw NomosTestHarnessException(
238
- 'connect("$workspace") did not complete within ${connectTimeout.inSeconds}s (phase 4/4) — '
239
- 'the runner is up but the session never mounted; a large ledger converge-fold can be slow, '
240
- 'raise connectTimeout, or run with onDiagnostic to see where it blocked'));
281
+ awaiting: useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
282
+ timeout: phaseTimeout ?? _kInstallDeadline,
283
+ onDiagnostic: onDiagnostic,
284
+ likelyCauses: const [
285
+ 'a cold npx download of githolon (pre-warm with any `githolon` run)',
286
+ 'a very large law package folding through the offline gate — raise phaseTimeout',
287
+ ],
288
+ run: () => _runInstall(
289
+ node: node,
290
+ useInRepoScript: useInRepoScript,
291
+ inRepoScript: resolvedHelper,
292
+ githolonCmd: githolonCmd,
293
+ deployJsonPaths: deployJsonPaths,
294
+ workspace: workspace,
295
+ snapshot: snapshot,
296
+ runtimeDir: runtimeDir,
297
+ onDiagnostic: onDiagnostic));
298
+
299
+ transport = await _phased(
300
+ phase: '2/4: spawn the node runner',
301
+ awaiting: 'Process.start($node)',
302
+ timeout: phaseTimeout ?? _kSpawnDeadline,
303
+ onDiagnostic: onDiagnostic,
304
+ run: () => NodeRunnerTransport.start(
305
+ entry: entry,
306
+ resolveFrom: resolveFrom,
307
+ node: node,
308
+ environment: {
309
+ 'NOMOS_OFFLINE': '1',
310
+ 'NOMOS_OFFLINE_RESTORE': snapshot,
311
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
312
+ },
313
+ onDiagnostic: onDiagnostic,
314
+ ));
315
+ final plane = NomosBridge(transport!);
316
+ await _phased(
317
+ phase: '3/4: runner ready',
318
+ awaiting: "event:'ready'",
319
+ timeout: phaseTimeout ?? readyTimeout,
320
+ onDiagnostic: onDiagnostic,
321
+ likelyCauses: const [
322
+ 'node is not on PATH, or ~/.holon/runtime is cold (holon.wasm + packages.json)',
323
+ 'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
324
+ ],
325
+ run: () => plane.ready);
326
+ await _phased(
327
+ phase: '4/4: connect session',
328
+ workspace: workspace,
329
+ awaiting: 'connect',
330
+ timeout: phaseTimeout ?? connectTimeout,
331
+ onDiagnostic: onDiagnostic,
332
+ likelyCauses: const [
333
+ 'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
334
+ 'run with onDiagnostic to see where the runner blocked',
335
+ ],
336
+ run: () => plane.connect(
337
+ cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
338
+ workspace: workspace,
339
+ clientId: clientId,
340
+ allowUnborn: true,
341
+ ));
241
342
  phase('ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
242
343
  // THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
243
344
  // single-workspace assumptions ship — with one session connected, an op that forgot its session
@@ -247,14 +348,20 @@ class NomosTestHarness {
247
348
  // against the harness keeps working unchanged; only code that grabs the raw plane and assumes
248
349
  // single-session trips. Opt out with `decoySession: false` (please don't).
249
350
  if (decoySession) {
250
- await plane.connect(
251
- cloud: 'https://offline.invalid',
252
- workspace: _decoyWorkspace,
253
- session: _decoyWorkspace,
254
- clientId: '$clientId-decoy',
255
- allowUnborn: true,
256
- restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
257
- );
351
+ await _phased(
352
+ phase: 'decoy: connect the decoy session',
353
+ workspace: _decoyWorkspace,
354
+ awaiting: 'connect',
355
+ timeout: phaseTimeout ?? connectTimeout,
356
+ onDiagnostic: onDiagnostic,
357
+ run: () async => plane.connect(
358
+ cloud: 'https://offline.invalid',
359
+ workspace: _decoyWorkspace,
360
+ session: _decoyWorkspace,
361
+ clientId: '$clientId-decoy',
362
+ allowUnborn: true,
363
+ restoreFrom: base64Encode(await File(snapshot).readAsBytes()),
364
+ ));
258
365
  }
259
366
  final harness = NomosTestHarness._(
260
367
  plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
@@ -271,7 +378,8 @@ class NomosTestHarness {
271
378
  inRepoScript: resolvedHelper,
272
379
  githolonCmd: githolonCmd,
273
380
  runtimeDir: runtimeDir,
274
- clientId: clientId));
381
+ clientId: clientId),
382
+ phaseTimeout: phaseTimeout);
275
383
  if (autoTearDown && registerTearDown != null) {
276
384
  registerTearDown(harness.dispose);
277
385
  }
@@ -317,6 +425,7 @@ class NomosTestHarness {
317
425
  void Function(String line)? onDiagnostic,
318
426
  Duration readyTimeout = const Duration(seconds: 30),
319
427
  Duration connectTimeout = const Duration(seconds: 120),
428
+ Duration? phaseTimeout,
320
429
  }) async {
321
430
  if (workspaces.isEmpty) {
322
431
  throw NomosTestHarnessException(
@@ -343,18 +452,29 @@ class NomosTestHarness {
343
452
  final snapshots = <String, String>{};
344
453
  final lawHashesByWs = <String, List<String>>{};
345
454
  String? frameworkHash;
455
+ var _i = 0;
346
456
  for (final e in workspaces.entries) {
347
457
  final snap = '${workdir.path}/${e.key}.bin';
348
- final info = await _runInstall(
349
- node: node,
350
- useInRepoScript: useInRepoScript,
351
- inRepoScript: resolvedHelper,
352
- githolonCmd: githolonCmd,
353
- deployJsonPaths: e.value,
458
+ final info = await _phased(
459
+ phase: '1: offline law install ${++_i}/${workspaces.length}',
354
460
  workspace: e.key,
355
- snapshot: snap,
356
- runtimeDir: runtimeDir,
357
- onDiagnostic: onDiagnostic);
461
+ awaiting: useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
462
+ timeout: phaseTimeout ?? _kInstallDeadline,
463
+ onDiagnostic: onDiagnostic,
464
+ likelyCauses: const [
465
+ 'a cold npx download of githolon (pre-warm with any `githolon` run)',
466
+ 'a very large law package folding through the offline gate — raise phaseTimeout',
467
+ ],
468
+ run: () => _runInstall(
469
+ node: node,
470
+ useInRepoScript: useInRepoScript,
471
+ inRepoScript: resolvedHelper,
472
+ githolonCmd: githolonCmd,
473
+ deployJsonPaths: e.value,
474
+ workspace: e.key,
475
+ snapshot: snap,
476
+ runtimeDir: runtimeDir,
477
+ onDiagnostic: onDiagnostic));
358
478
  snapshots[e.key] = snap;
359
479
  lawHashesByWs[e.key] = ((info['lawHashes'] as List?) ?? const [])
360
480
  .map((x) => x.toString())
@@ -364,23 +484,32 @@ class NomosTestHarness {
364
484
 
365
485
  // 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
366
486
  final phase = (String m) { if (onDiagnostic != null) onDiagnostic('[nomos-test-harness] $m'); };
367
- phase('cluster: spawning the node runner for ${workspaces.length} workspace(s) …');
368
- transport = await NodeRunnerTransport.start(
369
- entry: entry,
370
- resolveFrom: resolveFrom,
371
- node: node,
372
- environment: {
373
- 'NOMOS_OFFLINE': '1',
374
- if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
375
- },
376
- onDiagnostic: onDiagnostic,
377
- );
378
- final bridge = NomosBridge(transport);
379
- phase('cluster: awaiting runner ready (≤${readyTimeout.inSeconds}s) …');
380
- await bridge.ready.timeout(readyTimeout,
381
- onTimeout: () => throw NomosTestHarnessException(
382
- 'the offline runner never signalled ready within ${readyTimeout.inSeconds}s '
383
- '(is `node` on PATH and ~/.holon/runtime warm? run with onDiagnostic to see the runner stderr)'));
487
+ transport = await _phased(
488
+ phase: '2: spawn the node runner (${workspaces.length} workspace(s))',
489
+ awaiting: 'Process.start($node)',
490
+ timeout: phaseTimeout ?? _kSpawnDeadline,
491
+ onDiagnostic: onDiagnostic,
492
+ run: () => NodeRunnerTransport.start(
493
+ entry: entry,
494
+ resolveFrom: resolveFrom,
495
+ node: node,
496
+ environment: {
497
+ 'NOMOS_OFFLINE': '1',
498
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
499
+ },
500
+ onDiagnostic: onDiagnostic,
501
+ ));
502
+ final bridge = NomosBridge(transport!);
503
+ await _phased(
504
+ phase: '2: runner ready',
505
+ awaiting: "event:'ready'",
506
+ timeout: phaseTimeout ?? readyTimeout,
507
+ onDiagnostic: onDiagnostic,
508
+ likelyCauses: const [
509
+ 'node is not on PATH, or ~/.holon/runtime is cold (holon.wasm + packages.json)',
510
+ 'the runner process failed to boot the engine — run with onDiagnostic to see its stderr',
511
+ ],
512
+ run: () => bridge.ready);
384
513
 
385
514
  // 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
386
515
  // these are local githolons on the device plane, offline by nature).
@@ -388,35 +517,67 @@ class NomosTestHarness {
388
517
  var _n = 0;
389
518
  for (final e in workspaces.entries) {
390
519
  snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
391
- phase('cluster: connecting session ${++_n}/${workspaces.length} "${e.key}" (≤${connectTimeout.inSeconds}s) …');
392
- await bridge.connect(
393
- workspace: e.key,
394
- session: e.key,
395
- clientId: '$clientId-${e.key}',
396
- restoreFrom: snapshotB64[e.key],
397
- allowUnborn: true,
398
- ).timeout(connectTimeout,
399
- onTimeout: () => throw NomosTestHarnessException(
400
- 'connect("${e.key}") did not complete within ${connectTimeout.inSeconds}s — the runner is up '
401
- 'but this session never mounted (a large ledger converge-fold can be slow; raise connectTimeout '
402
- 'or run with onDiagnostic)'));
520
+ await _phased(
521
+ phase: '3: connect session ${++_n}/${workspaces.length}',
522
+ workspace: e.key,
523
+ awaiting: 'connect',
524
+ timeout: phaseTimeout ?? connectTimeout,
525
+ onDiagnostic: onDiagnostic,
526
+ likelyCauses: const [
527
+ 'a large ledger converge-fold can be slow — raise connectTimeout/phaseTimeout',
528
+ 'run with onDiagnostic to see where the runner blocked',
529
+ ],
530
+ run: () => bridge.connect(
531
+ workspace: e.key,
532
+ session: e.key,
533
+ clientId: '$clientId-${e.key}',
534
+ restoreFrom: snapshotB64[e.key],
535
+ allowUnborn: true,
536
+ ));
403
537
  }
404
538
  phase('cluster: ${workspaces.length} session(s) connected');
405
539
 
406
540
  // 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
407
541
  // RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
542
+ // The re-open is the runner's ATOMIC session replace (dispose-then-connect under the hood); each
543
+ // leg runs as a named, deadlined phase so a wedge is a typed HarnessPhaseTimeout, never a hang.
408
544
  final devicePublics = <String, String>{};
409
545
  for (final ws in deviceKeyed) {
410
- final kp = await bridge.session(ws).authorKeygen();
546
+ if (!workspaces.containsKey(ws)) {
547
+ throw NomosTestHarnessException(
548
+ 'deviceKeyed names "$ws" but it is not a workspace of this cluster — '
549
+ 'workspaces: ${workspaces.keys.toList()}');
550
+ }
551
+ final kp = await _phased(
552
+ phase: '3b: device keygen',
553
+ workspace: ws,
554
+ awaiting: 'authorKeygen',
555
+ timeout: phaseTimeout ?? _kOtherDeadline,
556
+ onDiagnostic: onDiagnostic,
557
+ run: () => bridge.session(ws).authorKeygen());
411
558
  devicePublics[ws] = (kp['public'] ?? '').toString();
412
- await bridge.connect(
413
- workspace: ws,
414
- session: ws,
415
- clientId: '$clientId-$ws',
416
- restoreFrom: snapshotB64[ws],
417
- authorSecret: (kp['secret'] ?? '').toString(),
418
- allowUnborn: true,
419
- );
559
+ await _phased(
560
+ phase: "3b: keyed reconnect of '$ws'",
561
+ workspace: ws,
562
+ awaiting: 'connect',
563
+ timeout: phaseTimeout ?? connectTimeout,
564
+ onDiagnostic: onDiagnostic,
565
+ likelyCauses: const [
566
+ 'the runner never answered the re-connect of an already-open session — '
567
+ 'update @githolon/client (the session re-open is an atomic replace)',
568
+ 'a large ledger re-fold on the re-mount — raise connectTimeout/phaseTimeout',
569
+ ],
570
+ run: () => bridge.connect(
571
+ workspace: ws,
572
+ session: ws,
573
+ clientId: '$clientId-$ws',
574
+ restoreFrom: snapshotB64[ws],
575
+ authorSecret: (kp['secret'] ?? '').toString(),
576
+ allowUnborn: true,
577
+ ));
578
+ }
579
+ if (deviceKeyed.isNotEmpty) {
580
+ phase('cluster: ${deviceKeyed.length} device-keyed session(s) re-opened');
420
581
  }
421
582
 
422
583
  final names = workspaces.keys.toList();
@@ -432,7 +593,8 @@ class NomosTestHarness {
432
593
  inRepoScript: resolvedHelper,
433
594
  githolonCmd: githolonCmd,
434
595
  runtimeDir: runtimeDir,
435
- clientId: clientId));
596
+ clientId: clientId),
597
+ phaseTimeout: phaseTimeout);
436
598
  if (autoTearDown && registerTearDown != null) {
437
599
  registerTearDown(harness.dispose);
438
600
  }
@@ -469,13 +631,19 @@ class NomosTestHarness {
469
631
  'resetWorkspaceCustody("$workspace"): no seed snapshot held for this workspace — '
470
632
  'seeded workspaces: ${_snapshots.keys.toList()}');
471
633
  }
472
- await _plane.connect(
473
- workspace: workspace,
474
- session: workspace,
475
- clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
476
- restoreFrom: snap,
477
- allowUnborn: true,
478
- );
634
+ await _phased(
635
+ phase: 'resetWorkspaceCustody: reconnect from seed snapshot',
636
+ workspace: workspace,
637
+ awaiting: 'connect',
638
+ timeout: _phaseTimeout ?? const Duration(seconds: 60),
639
+ onDiagnostic: _onDiagnostic,
640
+ run: () => _plane.connect(
641
+ workspace: workspace,
642
+ session: workspace,
643
+ clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
644
+ restoreFrom: snap,
645
+ allowUnborn: true,
646
+ ));
479
647
  }
480
648
 
481
649
  /// SEED a NEW workspace on this plane from `build/<name>.deploy.json` law packages — the
@@ -495,28 +663,40 @@ class NomosTestHarness {
495
663
  'seedWorkspaceFromDeployJson("$workspace"): pass the build/<name>.deploy.json paths');
496
664
  }
497
665
  final snap = '${_workdir.path}/$workspace.bin';
498
- final info = await _runInstall(
499
- node: lane.node,
500
- useInRepoScript: lane.useInRepoScript,
501
- inRepoScript: lane.inRepoScript,
502
- githolonCmd: lane.githolonCmd,
503
- deployJsonPaths: deployJsonPaths,
666
+ final info = await _phased(
667
+ phase: 'seed: offline law install',
504
668
  workspace: workspace,
505
- snapshot: snap,
506
- runtimeDir: lane.runtimeDir,
507
- onDiagnostic: _onDiagnostic);
669
+ awaiting: lane.useInRepoScript ? 'offline-install.mjs' : 'githolon harness-install',
670
+ timeout: _phaseTimeout ?? _kInstallDeadline,
671
+ onDiagnostic: _onDiagnostic,
672
+ run: () => _runInstall(
673
+ node: lane.node,
674
+ useInRepoScript: lane.useInRepoScript,
675
+ inRepoScript: lane.inRepoScript,
676
+ githolonCmd: lane.githolonCmd,
677
+ deployJsonPaths: deployJsonPaths,
678
+ workspace: workspace,
679
+ snapshot: snap,
680
+ runtimeDir: lane.runtimeDir,
681
+ onDiagnostic: _onDiagnostic));
508
682
  final b64 = base64Encode(await File(snap).readAsBytes());
509
683
  _snapshots[workspace] = b64;
510
684
  _lawHashes[workspace] = ((info['lawHashes'] as List?) ?? const [])
511
685
  .map((e) => e.toString())
512
686
  .toList();
513
- await _plane.connect(
514
- workspace: workspace,
515
- session: workspace,
516
- clientId: '${lane.clientId}-$workspace',
517
- restoreFrom: b64,
518
- allowUnborn: true,
519
- );
687
+ await _phased(
688
+ phase: 'seed: connect session',
689
+ workspace: workspace,
690
+ awaiting: 'connect',
691
+ timeout: _phaseTimeout ?? const Duration(seconds: 60),
692
+ onDiagnostic: _onDiagnostic,
693
+ run: () => _plane.connect(
694
+ workspace: workspace,
695
+ session: workspace,
696
+ clientId: '${lane.clientId}-$workspace',
697
+ restoreFrom: b64,
698
+ allowUnborn: true,
699
+ ));
520
700
  if (!_sessionNames.contains(workspace)) _sessionNames.add(workspace);
521
701
  return _plane.session(workspace);
522
702
  }
@@ -527,7 +707,13 @@ class NomosTestHarness {
527
707
  /// `harness.session(ws).assertCompatible(XClient.interface)`.)
528
708
  Future<void> assertDomainInstalled(
529
709
  {required String workspace, required String domain}) async {
530
- final offer = await _plane.session(workspace).interfaceOffer();
710
+ final offer = await _phased(
711
+ phase: 'assertDomainInstalled: read the interface offer',
712
+ workspace: workspace,
713
+ awaiting: 'interfaceOffer',
714
+ timeout: _phaseTimeout ?? _kOtherDeadline,
715
+ onDiagnostic: _onDiagnostic,
716
+ run: () => _plane.session(workspace).interfaceOffer());
531
717
  final keys = (offer?.keys.toList() ?? const <String>[])..sort();
532
718
  if (!keys.contains(domain)) {
533
719
  throw NomosTestHarnessException(
@@ -539,11 +725,21 @@ class NomosTestHarness {
539
725
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
540
726
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
541
727
  /// failed setup. Nothing left running, nothing left on disk.
728
+ /// Disposal must ALWAYS complete (never leave a hung teardown holding the test runner): the transport
729
+ /// dispose is deadlined too, and a breach is LOGGED (not thrown — throwing from teardown would mask the
730
+ /// test's own outcome) before the temp dir is still deleted.
542
731
  Future<void> dispose() async {
543
732
  if (_disposed) return;
544
733
  _disposed = true;
734
+ final f = _onDiagnostic;
735
+ if (f != null) f('[nomos-test-harness] phase dispose (≤${(_phaseTimeout ?? _kOtherDeadline).inSeconds}s) …');
545
736
  try {
546
- await _transport.dispose();
737
+ await _transport.dispose().timeout(_phaseTimeout ?? _kOtherDeadline,
738
+ onTimeout: () => throw HarnessPhaseTimeout(
739
+ phase: 'dispose: stop the node runner',
740
+ workspace: workspace,
741
+ elapsed: _phaseTimeout ?? _kOtherDeadline,
742
+ awaiting: 'NodeRunnerTransport.dispose'));
547
743
  } catch (e) {
548
744
  _diag('[nomos-test-harness] transport dispose error (continuing to temp-dir cleanup): $e');
549
745
  }
@@ -552,6 +748,7 @@ class NomosTestHarness {
552
748
  } catch (e) {
553
749
  _diag('[nomos-test-harness] temp-dir cleanup error: $e');
554
750
  }
751
+ if (f != null) f('[nomos-test-harness] phase dispose done');
555
752
  }
556
753
 
557
754
  void _diag(String line) {
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.49.0
14
+ version: 0.50.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,108 @@
1
+ // CO2'S ACCEPTANCE (verbatim, 2026-07): `NomosTestHarness.cluster(deviceKeyed: …)` re-opens the keyed
2
+ // session (keygen → keyed reconnect) and MUST NEVER hang — the reported wedge was phase 3b, the second
3
+ // `connect` on an already-open session. Acceptance:
4
+ // 1. cluster(..., deviceKeyed: {workspace}) completes (co2's exact shape: 4 sessions, one
5
+ // deviceKeyed, colon-namespaced workspace names)
6
+ // 2. devicePublicKey(workspace) is populated
7
+ // 3. a colocated attested read succeeds (served + SIGNED with the device key the re-open installed)
8
+ // 4. harness disposal completes without being killed
9
+ // Phase diagnostics (their ask 5) are asserted too: 3b logs enter/exit for keygen + keyed reconnect.
10
+ @TestOn('vm')
11
+ @Timeout(Duration(minutes: 3))
12
+ library;
13
+
14
+ import 'dart:io';
15
+
16
+ import 'package:nomos_client/nomos_headless.dart';
17
+ import 'package:test/test.dart';
18
+
19
+ String get _root => '${Directory.current.path}/../..';
20
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
21
+ String get _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
22
+ String get _estateDeploy => '$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
23
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
24
+
25
+ String? _missingPrereq() {
26
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
27
+ for (final p in [_catDeploy, _estateDeploy]) {
28
+ if (!File(p).existsSync()) return 'missing fixture $p (compile bench/scale/fixtures first)';
29
+ }
30
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
31
+ !File('$_runtimeDir/packages.json').existsSync()) {
32
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
33
+ }
34
+ return null;
35
+ }
36
+
37
+ void main() {
38
+ test('cluster(deviceKeyed) completes: keyed re-open, device key populated, colocated attested read, disposal',
39
+ () async {
40
+ final skip = _missingPrereq();
41
+ if (skip != null) {
42
+ markTestSkipped(skip);
43
+ return;
44
+ }
45
+
46
+ const keyed = 'catalogue:test-catalogue';
47
+ final log = <String>[];
48
+ // 1. cluster construction with a deviceKeyed workspace COMPLETES (the reported hang was here).
49
+ final harness = await NomosTestHarness.cluster(
50
+ workspaces: {
51
+ keyed: [_catDeploy],
52
+ 'estate:a': [_estateDeploy],
53
+ 'estate:b': [_estateDeploy],
54
+ 'estate:c': [_estateDeploy],
55
+ },
56
+ deviceKeyed: {keyed},
57
+ nodeEntry: _repoEntry,
58
+ runtimeDir: _runtimeDir,
59
+ onDiagnostic: log.add,
60
+ );
61
+ try {
62
+ expect(harness.sessions.toSet(),
63
+ {keyed, 'estate:a', 'estate:b', 'estate:c'});
64
+
65
+ // 2. devicePublicKey is populated for the keyed session.
66
+ final public = harness.devicePublicKey(keyed);
67
+ expect(public, isNotNull);
68
+ expect(public, isNotEmpty,
69
+ reason: 'the 3b keygen public half must be recorded on the harness');
70
+
71
+ // ask 5 — phase diagnostics: 3b logs enter AND exit for keygen + keyed reconnect.
72
+ expect(log.any((l) => l.contains('3b: device keygen')), isTrue,
73
+ reason: 'phase 3b keygen must log');
74
+ expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('…')),
75
+ isTrue, reason: 'phase 3b reconnect must log its enter line');
76
+ expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('done')),
77
+ isTrue, reason: 'phase 3b reconnect must log its exit line');
78
+
79
+ // 3. a COLOCATED ATTESTED READ succeeds, signed with the device key the re-open installed
80
+ // (no attestorSecret injected — the source session signs with its connect authorSecret).
81
+ final cat = harness.session(keyed);
82
+ final seeded = await cat.offerCreates(
83
+ domain: 'cat',
84
+ directiveId: 'listCatalogue',
85
+ payload: {
86
+ 'listingId': 'L-1',
87
+ 'title': 'Walnut desk',
88
+ 'priceGbp': '900',
89
+ 'listedAt': '2026-07-11T00:00:00Z',
90
+ },
91
+ actor: 'user:seller',
92
+ );
93
+ expect(seeded.head, isNotEmpty);
94
+ final served = await harness.session('estate:a').attestedRead(
95
+ from: keyed,
96
+ queryId: 'listingById',
97
+ args: {'listingId': 'L-1'},
98
+ );
99
+ final entry = (served['entry'] as Map).cast<String, Object?>();
100
+ final attestation = (entry['attestation'] as Map).cast<String, Object?>();
101
+ expect((attestation['public_key'] ?? '').toString(), public,
102
+ reason: 'the colocated read is signed by the re-opened session device key');
103
+ } finally {
104
+ // 4. disposal completes (the harness deadline guarantees it can never wedge the test runner).
105
+ await harness.dispose();
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // A FAKE offline-install helper for the hermetic HARNESS PHASE-TIMEOUT tests: honours the real helper's
3
+ // flag + one-line-JSON contract (--deploy/--workspace/--out) but installs nothing — it just writes a tiny
4
+ // dummy snapshot so the harness proceeds to the runner phases.
5
+ import { writeFileSync } from "node:fs";
6
+ const args = process.argv.slice(2);
7
+ const val = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
8
+ const out = val("--out");
9
+ const workspace = val("--workspace");
10
+ writeFileSync(out, Buffer.from("fake-snapshot"));
11
+ process.stdout.write(JSON.stringify({ ok: true, snapshot: out, workspace, lawHashes: ["deadbeef"], frameworkHash: "cafebabe" }) + "\n");
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // A WEDGING FAKE RUNNER for the hermetic HARNESS PHASE-TIMEOUT tests: speaks the NDJSON bridge protocol,
3
+ // answers everything — EXCEPT the op named in NOMOS_WEDGE_OP (optionally only when the args carry an
4
+ // authorSecret, i.e. the 3b keyed reconnect), which it swallows FOREVER. This forges the exact "silent
5
+ // hang" co2 reported so the harness's typed HarnessPhaseTimeout can be asserted without a real wait.
6
+ import { createInterface } from "node:readline";
7
+ const post = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
8
+ const wedgeOp = process.env.NOMOS_WEDGE_OP || "connect"; // default: wedge the KEYED reconnect (co2's shape)
9
+ const wedgeOnlyKeyed = process.env.NOMOS_WEDGE_ONLY_KEYED !== "0";
10
+ post({ v: 1, event: "ready" });
11
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
12
+ rl.on("line", (line) => {
13
+ const s = line.trim();
14
+ if (!s) return;
15
+ let msg; try { msg = JSON.parse(s); } catch { return; }
16
+ const a = msg.args || {};
17
+ if (msg.op === wedgeOp && (!wedgeOnlyKeyed || a.authorSecret)) return; // THE WEDGE: never answer
18
+ if (msg.op === "authorKeygen") return post({ v: 1, id: msg.id, ok: true, result: { secret: "c2VjcmV0", public: "cHVibGlj" } });
19
+ if (msg.op === "connect") return post({ v: 1, id: msg.id, ok: true, result: { workspace: a.workspace, session: a.session } });
20
+ post({ v: 1, id: msg.id, ok: true, result: {} });
21
+ });
22
+ rl.on("close", () => process.exit(0));
@@ -0,0 +1,72 @@
1
+ // NO SILENT HANGS — the harness's per-phase deadline machinery, proven HERMETICALLY (no real waits, no
2
+ // wasm, no network): a fake runner (test/fixtures/wedge/wedge-entry.mjs) speaks the bridge protocol but
3
+ // SWALLOWS the 3b keyed reconnect forever — the exact wedge co2 reported. With a short `phaseTimeout` the
4
+ // harness must throw the TYPED [HarnessPhaseTimeout] naming the phase/workspace/op, never hang; and the
5
+ // failed construction must tear the runner down by itself (no leaked process holding the test runner).
6
+ @TestOn('vm')
7
+ @Timeout(Duration(seconds: 60))
8
+ library;
9
+
10
+ import 'dart:io';
11
+
12
+ import 'package:nomos_client/nomos_headless.dart';
13
+ import 'package:test/test.dart';
14
+
15
+ String get _fixtures => '${Directory.current.path}/test/fixtures/wedge';
16
+ String get _wedgeEntry => '$_fixtures/wedge-entry.mjs';
17
+ String get _fakeInstall => '$_fixtures/fake-install.mjs';
18
+
19
+ void main() {
20
+ test('a wedged keyed reconnect fires HarnessPhaseTimeout naming phase 3b (typed, no hang)', () async {
21
+ final log = <String>[];
22
+ Object? err;
23
+ try {
24
+ await NomosTestHarness.cluster(
25
+ workspaces: {
26
+ 'catalogue': ['unused.deploy.json'], // the fake install helper ignores the paths
27
+ },
28
+ deviceKeyed: {'catalogue'},
29
+ nodeEntry: _wedgeEntry,
30
+ installHelper: _fakeInstall,
31
+ phaseTimeout: const Duration(seconds: 2), // hermetic: a short deadline, not a real 120s wait
32
+ onDiagnostic: log.add,
33
+ );
34
+ } catch (e) {
35
+ err = e;
36
+ }
37
+ expect(err, isA<HarnessPhaseTimeout>(),
38
+ reason: 'the wedge must surface as the typed phase timeout, never an eternal await');
39
+ final t = err as HarnessPhaseTimeout;
40
+ expect(t.phase, "3b: keyed reconnect of 'catalogue'");
41
+ expect(t.workspace, 'catalogue');
42
+ expect(t.awaiting, 'connect');
43
+ expect(t.elapsed, const Duration(seconds: 2));
44
+ expect(t.likelyCauses, isNotEmpty, reason: 'known wedges carry their likely causes');
45
+ expect(t, isA<NomosTestHarnessException>(),
46
+ reason: 'existing on-NomosTestHarnessException handling keeps working');
47
+ expect(t.toString(), contains("3b: keyed reconnect of 'catalogue'"));
48
+ // ask 5 — the phases before the wedge logged enter AND exit; the wedged phase logged its enter.
49
+ expect(log.any((l) => l.contains('3: connect session') && l.contains('done')), isTrue);
50
+ expect(log.any((l) => l.contains('3b: device keygen') && l.contains('done')), isTrue);
51
+ expect(log.any((l) => l.contains("3b: keyed reconnect of 'catalogue'")), isTrue);
52
+ });
53
+
54
+ test('deviceKeyed naming an unknown workspace refuses typed (never a wedge)', () async {
55
+ Object? err;
56
+ try {
57
+ await NomosTestHarness.cluster(
58
+ workspaces: {
59
+ 'catalogue': ['unused.deploy.json'],
60
+ },
61
+ deviceKeyed: {'not-a-workspace'},
62
+ nodeEntry: _wedgeEntry,
63
+ installHelper: _fakeInstall,
64
+ phaseTimeout: const Duration(seconds: 5),
65
+ );
66
+ } catch (e) {
67
+ err = e;
68
+ }
69
+ expect(err, isA<NomosTestHarnessException>());
70
+ expect(err.toString(), contains('not-a-workspace'));
71
+ });
72
+ }
@@ -123,10 +123,12 @@ void main() {
123
123
  );
124
124
  final joined = lines.join('\n');
125
125
  // the four named phases are surfaced so an AppSim can tell install vs runner-wait vs connect-block.
126
- expect(joined, contains('phase 1/4 installing'));
127
- expect(joined, contains('phase 2/4 spawning the node runner'));
128
- expect(joined, contains('phase 3/4 awaiting runner ready'));
129
- expect(joined, contains('phase 4/4 connecting session'));
126
+ expect(joined, contains('phase 1/4: offline law install'));
127
+ expect(joined, contains('phase 2/4: spawn the node runner'));
128
+ expect(joined, contains('phase 2/4: spawn the node runner done'));
129
+ expect(joined, contains('phase 3/4: runner ready'));
130
+ expect(joined, contains('phase 4/4: connect session'));
131
+ expect(joined, contains('phase 4/4: connect session "test-ws" done'));
130
132
  expect(joined, contains('ready — session "test-ws" connected'));
131
133
  expect(harness.bridge, isNotNull);
132
134
  }, timeout: const Timeout(Duration(minutes: 2)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -904,7 +904,7 @@ const NOMOS_TYPES_DEP = "^0.3.0";
904
904
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
905
905
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
906
906
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
907
- const NOMOS_CLIENT_DEP = "^0.49.0";
907
+ const NOMOS_CLIENT_DEP = "^0.50.0";
908
908
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
909
909
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
910
910
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).