@adhdev/daemon-core 0.9.82-rc.462 → 0.9.82-rc.464

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.
@@ -22,7 +22,7 @@ import { DaemonCliManager } from './cli-manager.js';
22
22
  import type { ProviderLoader } from '../providers/provider-loader.js';
23
23
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
24
24
  import { killIdeProcess, isIdeRunning } from '../launch.js';
25
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent } from '@adhdev/mesh-shared';
25
+ import { normalizeMeshNodeId, meshNodeIdMatches } from '@adhdev/mesh-shared';
26
26
  import { SessionRegistry } from '../sessions/registry.js';
27
27
  import { LOG } from '../logging/logger.js';
28
28
  import { logCommand } from '../logging/command-log.js';
@@ -42,27 +42,16 @@ import { execFileSync } from 'node:child_process';
42
42
 
43
43
  // ─── Extracted-module imports (symbols the dispatch class consumes) ───
44
44
  import {
45
- applyInlineMeshBranchConvergence,
46
- buildInlineMeshTransitGitStatus,
47
- buildLivePeerGitConnection,
48
- collectMeshNodeHostedSessionIds,
49
- deriveMeshNodeHealthFromGit,
50
45
  foldMeshNodeIdentityToCanonical,
51
46
  inlineMeshCarriesTransientNodeTruth,
52
- isDeadLocalWorktreeNode,
53
47
  MESH_DIRECT_PROBE_REUSE_MS,
54
48
  MeshGitProbeCache,
55
49
  normalizeInlineMeshNodeIdentity,
56
- readBooleanValue,
57
- readCachedInlineMeshActiveSessions,
58
50
  readInlineMeshNodeId,
59
- readMeshNodeDaemonId,
60
51
  readObjectRecord,
61
52
  readStringValue,
62
53
  reconcileInlineMeshCache,
63
54
  sanitizeInlineMesh,
64
- shouldRefreshStalePendingAggregate,
65
- summarizeInlineMeshBranchConvergence,
66
55
  } from '../mesh/mesh-node-identity.js';
67
56
  import {
68
57
  alignRefinerySubmodulesAfterMerge,
@@ -105,6 +94,14 @@ import {
105
94
  recordIntentionalMeshSessionStop,
106
95
  sessionMatchesMeshNode,
107
96
  } from './router-worktree-cleanup.js';
97
+ // ─── Aggregate mesh-status cache (bodies extracted from this file) ───
98
+ import {
99
+ getCachedAggregateMeshStatus,
100
+ hydrateCachedAggregateMeshStatusFromInline,
101
+ rememberAggregateMeshStatus,
102
+ } from './router-aggregate-status.js';
103
+ // ─── Remote mesh-session owner resolution (bodies extracted from this file) ───
104
+ import { resolveRemoteMeshSessionOwnerDaemonId } from './router-mesh-session-owner.js';
108
105
 
109
106
  // ─── Barrel re-exports: node-identity / git-freshness, refine gates, coordinator config ───
110
107
  // These modules were split out of router.ts. Re-export their public surface so the
@@ -299,8 +296,9 @@ export class DaemonCommandRouter {
299
296
  * on disk) clears the tombstone and merges normally, preserving clone
300
297
  * worktree visibility and legitimate node re-creation. */
301
298
  private removedInlineMeshNodeIds = new Map<string, Set<string>>();
302
- /** Coordinator-owned whole-mesh aggregate status snapshots. Browser callers read this by default. */
303
- private aggregateMeshStatusCache = new Map<string, { builtAt: number; snapshot: any; queueRevision: string }>();
299
+ /** Coordinator-owned whole-mesh aggregate status snapshots. Browser callers read this by default.
300
+ * Public (not private) so the extracted ./router-aggregate-status.ts orchestration can reach it via `self`. */
301
+ aggregateMeshStatusCache = new Map<string, { builtAt: number; snapshot: any; queueRevision: string }>();
304
302
  /** Shared per-peer git_status probe dedup + recently-probed reuse gate.
305
303
  * Spans separate mesh_status/get_mesh calls so the dashboard auto-retry
306
304
  * loop cannot storm a slow peer with back-to-back refreshUpstream probes. */
@@ -323,108 +321,14 @@ export class DaemonCommandRouter {
323
321
  this.deps = deps;
324
322
  }
325
323
 
326
- private cloneJsonValue<T>(value: T): T {
327
- if (typeof structuredClone === 'function') return structuredClone(value);
328
- return JSON.parse(JSON.stringify(value)) as T;
329
- }
324
+ // ─── Aggregate mesh-status cache ────────────────────────────────────
325
+ // Implementation lives in ./router-aggregate-status.ts (behavior-preserving
326
+ // code move). Kept here as thin delegators: getCachedAggregateMeshStatus /
327
+ // rememberAggregateMeshStatus are bound into HighFamilyContext, so callers
328
+ // reach these via `self.` for correct instance dispatch.
330
329
 
331
330
  private hydrateCachedAggregateMeshStatusFromInline(snapshot: any, mesh: any, options?: { requireDirectPeerTruth?: boolean }): any {
332
- if (!mesh || typeof mesh !== 'object' || !Array.isArray(mesh.nodes) || !Array.isArray(snapshot?.nodes)) return snapshot;
333
- const inlineNodesById = new Map<string, any>();
334
- for (const node of mesh.nodes) {
335
- const nodeId = readInlineMeshNodeId(node);
336
- if (nodeId) inlineNodesById.set(nodeId, node);
337
- }
338
- if (!inlineNodesById.size) return snapshot;
339
-
340
- let changed = false;
341
- const unavailableNodeIds = new Set<string>();
342
- const sourceOfTruth = readObjectRecord(snapshot.sourceOfTruth);
343
- const directPeerTruth = readObjectRecord(sourceOfTruth.directPeerTruth);
344
- // Dead local worktree nodes (isLocalWorktree, workspace deleted from disk)
345
- // carry no live truth and must never gate the aggregate as unavailable.
346
- // A cached snapshot built before the worktree was removed can still list
347
- // such a node in unavailableNodeIds, which would wedge the graph in a
348
- // permanent direct_peer_truth_unavailable; drop them here so the held
349
- // standing-state truth for the surviving nodes satisfies the aggregate.
350
- const deadNodeIds = new Set<string>();
351
- for (const node of mesh.nodes) {
352
- if (!isDeadLocalWorktreeNode(node)) continue;
353
- const deadId = readInlineMeshNodeId(node);
354
- if (deadId) deadNodeIds.add(deadId);
355
- }
356
- let droppedDeadUnavailable = false;
357
- for (const entry of Array.isArray(directPeerTruth.unavailableNodeIds) ? directPeerTruth.unavailableNodeIds : []) {
358
- const nodeId = readStringValue(entry);
359
- if (!nodeId) continue;
360
- if (deadNodeIds.has(nodeId)) {
361
- droppedDeadUnavailable = true;
362
- continue;
363
- }
364
- unavailableNodeIds.add(nodeId);
365
- }
366
- // Force a rewrite when a dead worktree was filtered out of a previously
367
- // built unavailable set, even if no live git was re-hydrated this pass —
368
- // otherwise the early-return below would hand back the stale snapshot that
369
- // still says direct_peer_truth_unavailable.
370
- if (droppedDeadUnavailable) changed = true;
371
-
372
- const nodes = snapshot.nodes.map((statusNode: any) => {
373
- const nodeId = normalizeMeshNodeId(statusNode);
374
- const inlineNode = nodeId ? inlineNodesById.get(nodeId) : undefined;
375
- if (!inlineNode) return statusNode;
376
- const liveGit = buildInlineMeshTransitGitStatus(inlineNode);
377
- if (!liveGit) return statusNode;
378
- const nextStatus = { ...statusNode };
379
- nextStatus.git = liveGit;
380
- nextStatus.health = deriveMeshNodeHealthFromGit(liveGit);
381
- applyInlineMeshBranchConvergence(mesh, inlineNode, nextStatus);
382
- nextStatus.launchReady = readBooleanValue(nextStatus.launchReady) ?? true;
383
- const connection = readObjectRecord(nextStatus.connection);
384
- const connectionState = readStringValue(connection.state);
385
- const connectionReported = readBooleanValue(connection.reported) ?? false;
386
- if (!connectionReported || connectionState === 'unknown') {
387
- nextStatus.connection = buildLivePeerGitConnection(connection);
388
- }
389
- delete nextStatus.gitProbePending;
390
- const error = readStringValue(nextStatus.error);
391
- if (error && /pending_git|git probe|live peer git snapshot|no peer git snapshot/i.test(error)) delete nextStatus.error;
392
- if (!readStringValue(nextStatus.machineStatus)) nextStatus.machineStatus = 'online';
393
- if (nodeId) unavailableNodeIds.delete(nodeId);
394
- changed = true;
395
- return nextStatus;
396
- });
397
-
398
- const aggregateDirectTruthSatisfied = sourceOfTruth.coordinatorOwnsLiveTruth === true
399
- || directPeerTruth.satisfied === true;
400
- if (!changed && !(options?.requireDirectPeerTruth && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied)) return snapshot;
401
- const nextSourceOfTruth = {
402
- ...sourceOfTruth,
403
- ...(Object.keys(directPeerTruth).length ? {
404
- directPeerTruth: {
405
- ...directPeerTruth,
406
- satisfied: options?.requireDirectPeerTruth === true
407
- ? aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0
408
- : directPeerTruth.satisfied,
409
- unavailableNodeIds: [...unavailableNodeIds],
410
- },
411
- ...(options?.requireDirectPeerTruth === true ? {
412
- coordinatorOwnsLiveTruth: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0,
413
- currentStatus: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0 ? 'live_git_and_session_probes' : 'direct_peer_truth_unavailable',
414
- } : {}),
415
- } : {}),
416
- };
417
- return {
418
- ...snapshot,
419
- ...(options?.requireDirectPeerTruth === true && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied ? {
420
- success: false,
421
- code: 'mesh_direct_peer_truth_unavailable',
422
- error: 'Selected coordinator could not confirm direct mesh truth for every remote node yet.',
423
- } : {}),
424
- sourceOfTruth: nextSourceOfTruth,
425
- branchConvergenceSummary: summarizeInlineMeshBranchConvergence(nodes),
426
- nodes,
427
- };
331
+ return hydrateCachedAggregateMeshStatusFromInline(this, snapshot, mesh, options);
428
332
  }
429
333
 
430
334
  private getCachedAggregateMeshStatus(
@@ -432,63 +336,11 @@ export class DaemonCommandRouter {
432
336
  mesh?: any,
433
337
  options?: { requireDirectPeerTruth?: boolean; allowStalePending?: boolean },
434
338
  ): any | null {
435
- const cached = this.aggregateMeshStatusCache.get(meshId);
436
- if (!cached?.snapshot || cached.snapshot.success !== true || !Array.isArray(cached.snapshot.nodes)) return null;
437
- // Genuine invalidation still forces truth: a queue mutation bumps the
438
- // revision, so a stale-revision snapshot is never served (even under the
439
- // SWR allowStalePending path below).
440
- if (cached.queueRevision !== getMeshQueueRevision(meshId)) return null;
441
- let snapshot = this.cloneJsonValue(cached.snapshot);
442
- snapshot = this.hydrateCachedAggregateMeshStatusFromInline(snapshot, mesh, options);
443
- // SWR: allowStalePending lets the interactive detail-open serve a snapshot
444
- // that still has pending peer-git nodes (would otherwise miss here) so the
445
- // graph paints instantly; the caller fires a background freshen. The
446
- // queueRevision guard above is NOT relaxed — only the pending-git freshness
447
- // gate is, so a genuine queue/identity mutation still forces a live rebuild.
448
- if (!options?.allowStalePending && shouldRefreshStalePendingAggregate(snapshot, options)) return null;
449
- const ageMs = Math.max(0, Date.now() - cached.builtAt);
450
- const sourceOfTruth = snapshot.sourceOfTruth && typeof snapshot.sourceOfTruth === 'object'
451
- ? snapshot.sourceOfTruth
452
- : {};
453
- snapshot.sourceOfTruth = {
454
- ...sourceOfTruth,
455
- aggregateSnapshot: {
456
- ...(sourceOfTruth.aggregateSnapshot && typeof sourceOfTruth.aggregateSnapshot === 'object'
457
- ? sourceOfTruth.aggregateSnapshot
458
- : {}),
459
- owner: 'coordinator_daemon_memory',
460
- cached: true,
461
- source: 'memory',
462
- refreshReason: 'memory_cache_hit',
463
- ageMs,
464
- cachedAt: new Date(cached.builtAt).toISOString(),
465
- returnedAt: new Date().toISOString(),
466
- },
467
- };
468
- return snapshot;
339
+ return getCachedAggregateMeshStatus(this, meshId, mesh, options);
469
340
  }
470
341
 
471
342
  private rememberAggregateMeshStatus(meshId: string, snapshot: any, refreshReason: string): any {
472
- if (!snapshot || typeof snapshot !== 'object' || snapshot.success !== true || !Array.isArray(snapshot.nodes)) return snapshot;
473
- const builtAt = Date.now();
474
- const next = this.cloneJsonValue(snapshot);
475
- const sourceOfTruth = next.sourceOfTruth && typeof next.sourceOfTruth === 'object'
476
- ? next.sourceOfTruth
477
- : {};
478
- next.sourceOfTruth = {
479
- ...sourceOfTruth,
480
- aggregateSnapshot: {
481
- owner: 'coordinator_daemon_memory',
482
- cached: false,
483
- source: 'live_refresh',
484
- refreshReason,
485
- ageMs: 0,
486
- cachedAt: new Date(builtAt).toISOString(),
487
- returnedAt: new Date(builtAt).toISOString(),
488
- },
489
- };
490
- this.aggregateMeshStatusCache.set(meshId, { builtAt, snapshot: this.cloneJsonValue(next), queueRevision: getMeshQueueRevision(meshId) });
491
- return next;
343
+ return rememberAggregateMeshStatus(this, meshId, snapshot, refreshReason);
492
344
  }
493
345
 
494
346
  public getCachedInlineMeshNodes(): any[] {
@@ -501,94 +353,14 @@ export class DaemonCommandRouter {
501
353
  return nodes;
502
354
  }
503
355
 
504
- /**
505
- * Resolve the REMOTE worker daemonId that owns a given session, when the session
506
- * belongs to a mesh node hosted on a DIFFERENT daemon than this coordinator.
507
- *
508
- * The coordinator does not host remote-worker session instances in its own
509
- * instanceManager/sessionRegistry — only their cached mesh-node metadata. A
510
- * dashboard-issued session-scoped command (invoke_provider_script / resolve_action /
511
- * set_mode / …) lands on the coordinator with a targetSessionId the coordinator can't
512
- * find locally, and without forwarding it dies as "Live session not found". send_chat
513
- * happens to survive (its target resolves to the worker by another route), but the
514
- * controlbar commands do not — so the controlbar buttons appear to do nothing.
515
- *
516
- * Mirror the existing node-level remote-forward pattern (fast_forward_mesh_node etc.):
517
- * scan the candidate mesh nodes for the one hosting the targetSessionId, and return its
518
- * daemonId when that daemonId is a remote daemon (i.e. not this coordinator's own
519
- * statusInstanceId). Returns undefined for a locally-hosted session (no forward — execute
520
- * locally as before) or when ownership can't be resolved.
521
- *
522
- * The candidate set spans BOTH the cached inline-mesh nodes and the live aggregate
523
- * mesh-status snapshots. The inline cache reliably carries only each node's single primary
524
- * session (cachedStatus.activeSession), so a worker hosting more than one session exposes its
525
- * non-primary sessions only on the aggregate snapshot nodes (status.activeSessions /
526
- * activeSessionDetails, built from live session records). collectMeshNodeHostedSessionIds does
527
- * the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
528
- * session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
529
- * other consumers depend on stay untouched.
530
- *
531
- * CANCEL-STOP-RELAY: the session-id cache scan above only matches when the coordinator's
532
- * cached status snapshot already lists the worker's session id in a recognized active-sessions
533
- * shape. A worktree-clone worker session whose id form/timing differs from the cached snapshot
534
- * (or is simply not yet reflected) misses the scan, so a stop that carries the authoritative
535
- * owning nodeId (mesh_queue_cancel knows assignedNodeId) used to silently fail to forward.
536
- * `ownerNodeIdHint` adds a deterministic fallback: when the session-id scan misses, resolve the
537
- * owner daemonId by matching the node by id (meshNodeIdMatches — same form-tolerant compare the
538
- * rest of the router uses, no new raw compare). The same self-loopback guard applies to both
539
- * paths, so a coordinator-hosted node is still never force-forwarded to a remote form of itself.
540
- */
541
- public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string, ownerNodeIdHint?: string): string | undefined {
542
- const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
543
- const nodeHint = typeof ownerNodeIdHint === 'string' ? ownerNodeIdHint.trim() : '';
544
- if (!trimmed && !nodeHint) return undefined;
545
- const selfDaemonId = this.deps.statusInstanceId;
546
- const candidates = this.collectMeshSessionOwnerCandidateNodes();
547
- if (trimmed) {
548
- for (const node of candidates) {
549
- if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
550
- const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
551
- // A matching node with no readable daemonId can't be attributed — keep scanning
552
- // the remaining candidates (e.g. the same session on an aggregate node that does
553
- // carry the daemonId) rather than bailing on the whole resolution.
554
- if (!nodeDaemonId) continue;
555
- // Only forward to a genuinely remote daemon. When the owning node is this
556
- // coordinator itself (locally hosted worker), fall through to local handling.
557
- // id-form robust: the node daemonId and selfDaemonId may be stored in different
558
- // forms of the same machine — a strict `===` would miss the self-match and forward
559
- // a local session to a remote form of THIS daemon (loopback).
560
- if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
561
- return nodeDaemonId;
562
- }
563
- }
564
- // Deterministic fallback: the session-id scan missed (cache lag / id-form mismatch on a
565
- // worktree-clone worker), but the caller knows the authoritative owning nodeId. Resolve the
566
- // owner daemonId straight off that node — never the fuzzy session cache.
567
- if (nodeHint) {
568
- for (const node of candidates) {
569
- if (!meshNodeIdMatches(node, nodeHint)) continue;
570
- const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
571
- if (!nodeDaemonId) continue;
572
- if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
573
- return nodeDaemonId;
574
- }
575
- }
576
- return undefined;
577
- }
356
+ // ─── Remote mesh-session owner resolution ───────────────────────────
357
+ // Implementation lives in ./router-mesh-session-owner.ts (behavior-preserving
358
+ // code move). resolveRemoteMeshSessionOwnerDaemonId stays public (the [Z]
359
+ // session-scoped forward in executeDaemonCommand and a unit test call it), so
360
+ // it's kept here as a thin delegator.
578
361
 
579
- /**
580
- * Candidate nodes for remote-session owner resolution: the cached inline-mesh nodes (which
581
- * carry each node's primary session) plus the nodes from every cached aggregate mesh-status
582
- * snapshot (which carry each node's full live session list). getCachedInlineMeshNodes()
583
- * returns a fresh array, so appending the aggregate nodes never mutates cached state.
584
- */
585
- private collectMeshSessionOwnerCandidateNodes(): any[] {
586
- const nodes: any[] = this.getCachedInlineMeshNodes();
587
- for (const cached of this.aggregateMeshStatusCache.values()) {
588
- const snapshotNodes = cached?.snapshot?.nodes;
589
- if (Array.isArray(snapshotNodes)) nodes.push(...snapshotNodes);
590
- }
591
- return nodes;
362
+ public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string, ownerNodeIdHint?: string): string | undefined {
363
+ return resolveRemoteMeshSessionOwnerDaemonId(this, sessionId, ownerNodeIdHint);
592
364
  }
593
365
 
594
366
  public getCachedInlineMesh(meshId: string, inlineMesh?: unknown): any | undefined {
@@ -138,15 +138,16 @@ function normalizeCoordinatorDaemonIds(
138
138
  * T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
139
139
  * (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
140
140
  * it instead (excluded from the delivered batch + WARN + counter), and unicast
141
- * routing is the only delivery path (there is no v1 broadcast fallback). Off (the
142
- * default) preserves the accept-and-warn rollout behaviour exactly.
141
+ * routing is the only delivery path (there is no v1 broadcast fallback). On by
142
+ * default; set MESH_PROTOCOL_V2_ENFORCE=0/false/off/no to disable and restore the
143
+ * accept-and-warn rollout behaviour exactly.
143
144
  *
144
- * Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
145
- * its activation is a deliberate operational step taken ONLY after daemonBuilds
146
- * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
147
- * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
148
- * no data migration the schema is additive). Read at call time so a test /
149
- * operator can toggle it without a restart.
145
+ * Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env).
146
+ * Now that every node emits v2 (§배포 게이트 1 / risk §4), the code default is ON —
147
+ * a manual env injection is no longer required to get enforce behaviour. Rollback to
148
+ * accept mode is a pure-env step: set `MESH_PROTOCOL_V2_ENFORCE=0` (or `false`/`off`/
149
+ * `no`) — no code change, no data migration (the schema is additive). Read at call
150
+ * time so a test / operator can toggle it without a restart.
150
151
  *
151
152
  * Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
152
153
  * already consumed the event from its store by the time routing runs, so "held
@@ -159,9 +160,9 @@ function normalizeCoordinatorDaemonIds(
159
160
  */
160
161
  export function isMeshProtocolV2EnforceEnabled(): boolean {
161
162
  const raw = readNonEmptyString(process.env.MESH_PROTOCOL_V2_ENFORCE);
162
- if (!raw) return false;
163
+ if (!raw) return true; // unset/blank = default ON
163
164
  const v = raw.trim().toLowerCase();
164
- return v === '1' || v === 'true' || v === 'on' || v === 'yes';
165
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no'); // only explicit off = false
165
166
  }
166
167
 
167
168
  /**
@@ -294,12 +294,13 @@ export function __resetMeshV2BackstopCountersForTests(): void {
294
294
 
295
295
  /** Enforce switch mirror (see isMeshProtocolV2EnforceEnabled in mesh-events-pending);
296
296
  * re-read here (not imported) to keep the reconcile loop free of a cross-file coupling
297
- * and to read env at fire time. Same truthy vocabulary. */
297
+ * and to read env at fire time. On by default; set MESH_PROTOCOL_V2_ENFORCE=0/false/
298
+ * off/no to disable. Same vocabulary as the source of truth. */
298
299
  function meshProtocolV2EnforceOn(): boolean {
299
300
  const raw = process.env.MESH_PROTOCOL_V2_ENFORCE;
300
- if (typeof raw !== 'string') return false;
301
+ if (typeof raw !== 'string' || !raw.trim()) return true; // unset/blank = default ON
301
302
  const v = raw.trim().toLowerCase();
302
- return v === '1' || v === 'true' || v === 'on' || v === 'yes';
303
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
303
304
  }
304
305
 
305
306
  /** Bump a backstop counter and, under enforce, WARN that a last-resort net fired —
@@ -1517,6 +1517,22 @@ export class CliProviderInstance implements ProviderInstance {
1517
1517
  return probe;
1518
1518
  }
1519
1519
 
1520
+ /**
1521
+ * The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
1522
+ * at a per-coordinator HERMES_HOME so its state.db lives in a tmpdir instead
1523
+ * of ~/.hermes). The native-history executor expands `${HERMES_HOME:-~/.hermes}`
1524
+ * from this map, so the completion gate MUST pass it through — otherwise the
1525
+ * gate reads ~/.hermes, finds no coordinator-session transcript, and
1526
+ * false-fires missing_final_assistant on every coordinator turn.
1527
+ */
1528
+ private spawnedEnvOverrides(): Record<string, string> | undefined {
1529
+ const meta = typeof (this.adapter as any)?.getRuntimeMetadata === 'function'
1530
+ ? (this.adapter as any).getRuntimeMetadata()
1531
+ : undefined;
1532
+ const env = meta && typeof meta === 'object' ? (meta as Record<string, unknown>).spawnedEnv : undefined;
1533
+ return env && typeof env === 'object' ? env as Record<string, string> : undefined;
1534
+ }
1535
+
1520
1536
  private readExternalCompletionMessages(): unknown[] | null {
1521
1537
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1522
1538
  if (!adapterOwnsMessagesElsewhere) return null;
@@ -1535,6 +1551,7 @@ export class CliProviderInstance implements ProviderInstance {
1535
1551
  historyBehavior: this.provider.historyBehavior,
1536
1552
  scripts: this.provider.scripts as any,
1537
1553
  sessionStartedAtMs: this.startedAt,
1554
+ envOverrides: this.spawnedEnvOverrides(),
1538
1555
  forceRefresh: true,
1539
1556
  });
1540
1557
  if (restoredHistory.source !== 'provider-native') {
@@ -69,7 +69,59 @@ function openDb(): any | null {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * Expand an anchor session id to every session id in its logical cluster.
74
+ *
75
+ * hermes ≥0.14 splits a SINGLE logical turn across several `sessions` rows
76
+ * linked by `parent_session_id` (a 0-message intermediate row is common), and
77
+ * the turn's final assistant message lands in a DIFFERENT row than the one the
78
+ * daemon pins. A bidirectional walk — up the parent chain to the cluster root,
79
+ * then down through every descendant — returns the complete set from ANY anchor
80
+ * (root, middle, or leaf), mirroring the declarative executor's
81
+ * `session_cluster_query`. Falls back to the anchor alone if the schema has no
82
+ * `parent_session_id` column (older hermes) or the walk fails.
83
+ */
84
+ function resolveClusterSessionIds(db: any, anchorId: string): string[] {
85
+ if (!anchorId) return [];
86
+ try {
87
+ const rows: any[] = db.prepare(
88
+ `WITH RECURSIVE
89
+ up(id) AS (
90
+ SELECT id FROM sessions WHERE id = ?
91
+ UNION
92
+ SELECT s.parent_session_id FROM sessions s JOIN up ON s.id = up.id
93
+ WHERE s.parent_session_id IS NOT NULL
94
+ ),
95
+ cluster(id) AS (
96
+ SELECT id FROM up
97
+ UNION
98
+ SELECT s.id FROM sessions s JOIN cluster ON s.parent_session_id = cluster.id
99
+ )
100
+ SELECT id FROM cluster`,
101
+ ).all(anchorId);
102
+ const ids = new Set<string>([anchorId]);
103
+ for (const r of rows) {
104
+ if (r && r.id != null && String(r.id)) ids.add(String(r.id));
105
+ }
106
+ return Array.from(ids);
107
+ } catch {
108
+ // No parent_session_id column (older hermes) or a walk failure — the
109
+ // single anchor is still a valid (degenerate) cluster.
110
+ return [anchorId];
111
+ }
112
+ }
113
+
72
114
  function loadMessagesForSession(db: any, sessionId: string): NativeHistoryMessage[] {
115
+ // Read the WHOLE sub-session cluster, not just the pinned anchor. hermes
116
+ // ≥0.14 writes a turn's final assistant into a descendant sub-session row,
117
+ // so an anchor-only read misses it → read_chat shows zero assistant bubbles
118
+ // and the completion gate false-fires missing_final_assistant. Gather every
119
+ // cluster member (parent-chain walk) and merge; the SQL `ORDER BY timestamp`
120
+ // re-interleaves bubbles from different sub-sessions into true chronological
121
+ // order so the final assistant lands last.
122
+ const clusterIds = resolveClusterSessionIds(db, sessionId);
123
+ if (clusterIds.length === 0) return [];
124
+ const placeholders = clusterIds.map(() => '?').join(', ');
73
125
  // Assistant turns whose finish_reason='tool_calls' persist an EMPTY
74
126
  // `content` — their payload lives in the `tool_calls` column. Filtering on
75
127
  // `content != ''` alone drops those rows, so a turn whose terminal message
@@ -79,10 +131,10 @@ function loadMessagesForSession(db: any, sessionId: string): NativeHistoryMessag
79
131
  const rows: any[] = db.prepare(
80
132
  `SELECT id, role, COALESCE(NULLIF(content, ''), tool_calls) AS content, timestamp
81
133
  FROM messages
82
- WHERE session_id = ?
134
+ WHERE session_id IN (${placeholders})
83
135
  AND ((content IS NOT NULL AND content != '') OR (tool_calls IS NOT NULL AND tool_calls != ''))
84
136
  ORDER BY timestamp ASC, id ASC`,
85
- ).all(sessionId);
137
+ ).all(...clusterIds);
86
138
  const out: NativeHistoryMessage[] = [];
87
139
  for (const r of rows) {
88
140
  const role = normalizeHermesRole(r.role);
@@ -279,12 +279,53 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
279
279
  // this schema-agnostic and only rescues the mis-bound-id case: a genuine
280
280
  // discovered pin (codex/claude use jsonl sources and never reach here;
281
281
  // any real sqlite pin has rows) still short-circuits on its own rows.
282
- const resolveMessagesFor = (sessionId: string): any[] | null => {
283
- if (!sessionId) return null;
284
- let rows: any[];
285
- try { rows = db.prepare(src.message_query).all(sessionId); }
286
- catch { return null; }
287
- return rows && rows.length > 0 ? rows : null;
282
+ // Expand an anchor session id to every session id in its logical
283
+ // cluster. When the spec declares `session_cluster_query` the anchor is
284
+ // run through it (bound `?`) and each returned row's FIRST column is a
285
+ // cluster member id typically a WITH RECURSIVE walk up to the cluster
286
+ // root and back down through all descendants, so passing a root, middle,
287
+ // or leaf anchor all resolve the same complete set. The anchor is always
288
+ // included even if the query omits it (defensive) so a spec with no
289
+ // cluster query, or a query that returns nothing, still reads the anchor
290
+ // itself. Absent query → just the anchor (single-session behaviour).
291
+ const resolveClusterIds = (anchorId: string): string[] => {
292
+ const ids = new Set<string>();
293
+ if (anchorId) ids.add(anchorId);
294
+ if (src.session_cluster_query && anchorId) {
295
+ try {
296
+ const rows: any[] = db.prepare(src.session_cluster_query).all(anchorId);
297
+ for (const row of rows) {
298
+ const idRaw = Object.values(row)[0];
299
+ if (idRaw != null && String(idRaw)) ids.add(String(idRaw));
300
+ }
301
+ } catch { /* fall back to anchor-only on a malformed cluster query */ }
302
+ }
303
+ return Array.from(ids);
304
+ };
305
+
306
+ // Read messages for an anchor's WHOLE cluster, merged and re-sorted by
307
+ // their mapped timestamp so bubbles from different sub-sessions interleave
308
+ // in true chronological order (the turn's final assistant — written into a
309
+ // descendant sub-session in the split-turn case — lands last). No per-session
310
+ // short-circuit: an anchor whose OWN row has zero messages (hermes writes a
311
+ // 0-message intermediate `sessions` row) still yields the cluster's rows,
312
+ // and the whole cluster is scanned rather than stopping at the first
313
+ // non-empty session. Returns null only when the ENTIRE cluster is empty,
314
+ // preserving the pin-validation contract below (a pin that resolves no rows
315
+ // anywhere is a mis-bound id and falls through to newest-session recovery).
316
+ const resolveMessagesFor = (anchorId: string): any[] | null => {
317
+ if (!anchorId) return null;
318
+ const clusterIds = resolveClusterIds(anchorId);
319
+ const merged: any[] = [];
320
+ for (const id of clusterIds) {
321
+ let rows: any[];
322
+ try { rows = db.prepare(src.message_query).all(id); }
323
+ catch { continue; }
324
+ if (rows && rows.length > 0) merged.push(...rows);
325
+ }
326
+ if (merged.length === 0) return null;
327
+ if (clusterIds.length > 1) sortRowsByMappedTimestamp(merged, src.message_map);
328
+ return merged;
288
329
  };
289
330
 
290
331
  const resolveNewestSessionId = (): string => {
@@ -362,6 +403,32 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
362
403
  }
363
404
  }
364
405
 
406
+ /**
407
+ * Stable-sort merged cluster rows by their mapped timestamp so bubbles read
408
+ * from different sub-sessions interleave in true chronological order. Uses the
409
+ * same `message_map.timestamp_ms` jsonpath + `parseTimestamp` heuristic the
410
+ * projection uses, so the sort key agrees with the receivedAt each row will be
411
+ * given. Rows with no resolvable timestamp keep their pre-sort relative order
412
+ * (stable), and equal timestamps preserve insertion order — both matter because
413
+ * a turn's terminal bubbles can share a sub-second timestamp.
414
+ */
415
+ function sortRowsByMappedTimestamp(rows: any[], map: NativeHistoryMessageMap): void {
416
+ if (!map.timestamp_ms) return;
417
+ const keyed = rows.map((row, index) => {
418
+ const parsed = parseTimestamp(jsonPathGet(row, map.timestamp_ms as string));
419
+ return { row, index, ts: parsed == null ? Number.NaN : parsed };
420
+ });
421
+ keyed.sort((a, b) => {
422
+ const aHas = !Number.isNaN(a.ts);
423
+ const bHas = !Number.isNaN(b.ts);
424
+ if (aHas && bHas && a.ts !== b.ts) return a.ts - b.ts;
425
+ // Missing-timestamp rows and ties fall back to original insertion order
426
+ // so the sort stays stable.
427
+ return a.index - b.index;
428
+ });
429
+ for (let i = 0; i < keyed.length; i += 1) rows[i] = keyed[i].row;
430
+ }
431
+
365
432
  // ────────────────────────────────────────────────────────────────────────────
366
433
  // Path expansion + globbing
367
434
  // ────────────────────────────────────────────────────────────────────────────
@@ -103,6 +103,28 @@ export interface NativeHistorySqliteSource {
103
103
  path: string;
104
104
  session_query: string;
105
105
  message_query: string;
106
+ /**
107
+ * Optional sub-session cluster expansion. Some agents (hermes ≥0.14) split
108
+ * a SINGLE logical turn across several `sessions` rows linked by a parent
109
+ * pointer, and the turn's final assistant message lands in a DIFFERENT row
110
+ * than the one `session_query` / the daemon's pin resolves. Reading only
111
+ * the anchor session then surfaces zero (or stale) assistant bubbles even
112
+ * though the answer is physically present in a sibling/descendant row —
113
+ * `read_chat` returns no final assistant and the completion gate false-fires
114
+ * `missing_final_assistant`.
115
+ *
116
+ * When present, the executor treats the resolved session id as an ANCHOR
117
+ * and runs this query (bound `?` = anchor id) to expand it to every session
118
+ * id in the same logical cluster (typically a `WITH RECURSIVE` walk over the
119
+ * parent pointer, up to the root and back down through all descendants).
120
+ * `message_query` is then run once per cluster id and the rows merged and
121
+ * re-sorted by their mapped timestamp, so the turn's final assistant — in
122
+ * whichever sub-session it was written — is always included. Each returned
123
+ * row's first column is a cluster session id.
124
+ *
125
+ * Absent → single-session behaviour is unchanged (anchor session only).
126
+ */
127
+ session_cluster_query?: string;
106
128
  message_map: NativeHistoryMessageMap;
107
129
  }
108
130