@githolon/dsl 0.73.0 → 0.75.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,50 @@
1
+ ## 0.51.0
2
+
3
+ - **The burn-down wave.** Dead-letter queue surface on the session plane: `deadLetters()`,
4
+ `retryDeadLetter()`, `discardDeadLetter()`, `salvage()`, `absorbSalvage()` — with the local-only
5
+ retry/absorb fixes and the salvage-shape parking fix (refused work is NEVER silently dropped).
6
+ `nomos_headless.dart` exports the full session surface. Requires Nomos Cloud ≥ the 2026-07-11
7
+ burn-down deploy for the fleet DLQ sweep lanes (`/dlq-summary`, `/dlq-sweep`) and the
8
+ content-addressed large-deploy staging lane.
9
+
10
+ ## 0.50.1
11
+
12
+ - **Structural: the session-delegation-gap class is closed.** All event-dispatch registries
13
+ (watch subscriptions, compat watches, custody/sync/signal/flame/span/transfer/status
14
+ controllers, span buffers, session registries) now route through one `_eventPlane` seam that
15
+ resolves to the plane owning the message loop — the base class is correct for `NomosSession`
16
+ automatically, and all 17 hand-written per-method session overrides are DELETED (including the
17
+ 0.48.1 `watchPendingConflicts` fix, whose regression test still passes — the proof). Five
18
+ latent unreported instances of the same bug fixed en passant: `session.custodyEvents` (dead
19
+ stream), `connectedSessions` (empty), `connect()`-on-session state stamping, `checkCompat()`
20
+ caching, `noteLocalSpan()` (dead buffer). A source-level tripwire test fails the suite if a
21
+ future Stream-returning method bypasses the seam.
22
+ - **The first-five-minutes gauntlet** — one checklist test walking the entire promised surface
23
+ (deviceKeyed cluster + phase diagnostics, the blessed session-bound shape, local autoSync
24
+ posture with zero-fetch assertion, acquireSession single-flight, reactive first-snapshots,
25
+ attested keyed reads, custody reset/re-seed, deadline-bounded disposal) — now runs in CI on
26
+ every client/runner/codegen change. Every one of this week's four tenant-reported lifecycle
27
+ bugs appears as an explicit leg.
28
+
29
+ ## 0.50.0
30
+
31
+ - **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
32
+ has a deadline (overridable via the new `phaseTimeout` param) and throws a typed
33
+ `HarnessPhaseTimeout` naming the phase (e.g. `"3b: keyed reconnect of 'catalogue'"`), workspace,
34
+ elapsed time, the awaited bridge op, and likely causes — a wedge is a 30-second named exception,
35
+ never a 40-minute bisection. Every phase logs terse enter/exit diagnostics. Disposal deadline
36
+ breaches are logged, not thrown — teardown always completes.
37
+ - **Keyed-reconnect lane hardened** (the `deviceKeyed` cluster hang report): harness phase 3b
38
+ (keygen + keyed reconnect) had no timeout and no diagnostics — any wedge there hung silently
39
+ after "4 session(s) connected". Additionally two runner/realm defects fixed: the stale session
40
+ record stayed routable during an async re-open (concurrent ops could hit the disposed holon —
41
+ now an atomic replace with a fast typed refusal in the window), and a re-open with new
42
+ credentials but no `restoreFrom` silently kept the OLD identity (the fresh authorSecret was
43
+ never installed) — an identity-bearing option change now forces an in-place re-mount over
44
+ resident custody.
45
+ - co2's acceptance implemented verbatim: `cluster(deviceKeyed:)` completes, `devicePublicKey`
46
+ populated, colocated attested read signed by the re-opened key, disposal completes.
47
+
1
48
  ## 0.49.0
2
49
 
3
50
  - **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
@@ -39,3 +39,11 @@ library;
39
39
 
40
40
  export 'src/node_runner_transport.dart';
41
41
  export 'src/test_harness.dart';
42
+ // THE FULL SESSION SURFACE (task #86 gap 1): a headless test types everything the harness hands out —
43
+ // [NomosSessionCredentials] (the `acquireSession` ifAbsentCredentials callback's return type),
44
+ // [NomosBridge]/[NomosSession]/[NomosBbox]/[NomosCompat], the E2E sealing client
45
+ // ([NomosKeyStore]/[WrappedKeyStore]/[NomosEncryption]), and the typed session-bound errors —
46
+ // WITHOUT also importing the web barrel. Both files are web-safe (they are exported from
47
+ // `nomos_client.dart` too); re-exporting them here just makes the headless import self-sufficient.
48
+ export 'src/webview_bridge.dart';
49
+ export 'src/session_bound.dart';
@@ -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) {