@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.
- package/dist/commands/router-aggregate-status.d.ts +24 -0
- package/dist/commands/router-mesh-session-owner.d.ts +59 -0
- package/dist/commands/router.d.ts +7 -47
- package/dist/index.js +285 -219
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +285 -219
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +9 -8
- package/dist/providers/cli-provider-instance.d.ts +9 -0
- package/dist/providers/spec/types.d.ts +22 -0
- package/package.json +3 -3
- package/src/commands/router-aggregate-status.ts +209 -0
- package/src/commands/router-mesh-session-owner.ts +114 -0
- package/src/commands/router.ts +27 -255
- package/src/mesh/mesh-events-pending.ts +11 -10
- package/src/mesh/mesh-reconcile-loop.ts +4 -3
- package/src/providers/cli-provider-instance.ts +17 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +54 -2
- package/src/providers/spec/native-history-executor.ts +73 -6
- package/src/providers/spec/types.ts +22 -0
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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).
|
|
142
|
-
* default
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* no data migration
|
|
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
|
|
163
|
+
if (!raw) return true; // unset/blank = default ON
|
|
163
164
|
const v = raw.trim().toLowerCase();
|
|
164
|
-
return v === '
|
|
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.
|
|
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
|
|
301
|
+
if (typeof raw !== 'string' || !raw.trim()) return true; // unset/blank = default ON
|
|
301
302
|
const v = raw.trim().toLowerCase();
|
|
302
|
-
return v === '
|
|
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(
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|