@adhdev/daemon-core 0.9.82-rc.468 → 0.9.82-rc.469
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/index.d.ts +7 -5
- package/dist/index.js +683 -495
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +674 -493
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +0 -27
- package/dist/mesh/mesh-events-pending.d.ts +40 -0
- package/dist/mesh/mesh-events.d.ts +2 -2
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
- package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
- package/dist/mesh/mesh-runtime-store.d.ts +0 -22
- package/dist/mesh/mesh-work-queue.d.ts +45 -0
- package/package.json +3 -3
- package/src/index.ts +11 -5
- package/src/mesh/coordinator-prompt.ts +15 -0
- package/src/mesh/mesh-completion-synthesis.ts +398 -0
- package/src/mesh/mesh-delivery-policy.ts +7 -38
- package/src/mesh/mesh-event-forwarding.ts +9 -10
- package/src/mesh/mesh-events-pending.ts +176 -0
- package/src/mesh/mesh-events.ts +6 -1
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-queue-assignment.ts +16 -2
- package/src/mesh/mesh-reconcile-config.ts +66 -0
- package/src/mesh/mesh-reconcile-loop.ts +21 -674
- package/src/mesh/mesh-remote-event-pull.ts +279 -0
- package/src/mesh/mesh-runtime-store.ts +46 -82
- package/src/mesh/mesh-work-queue.ts +90 -0
|
@@ -41,11 +41,10 @@
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
|
|
43
43
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
44
|
-
import type { LocalMeshEntry } from '../repo-mesh-types.js';
|
|
45
44
|
import { loadConfig } from '../config/config.js';
|
|
46
45
|
import { listMeshes } from '../config/mesh-config.js';
|
|
47
46
|
import { LOG, getLogLevel } from '../logging/logger.js';
|
|
48
|
-
import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent
|
|
47
|
+
import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
49
48
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
50
49
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
51
50
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
@@ -59,93 +58,37 @@ import {
|
|
|
59
58
|
import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
|
|
60
59
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
61
60
|
import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
|
|
62
|
-
import {
|
|
61
|
+
import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
|
|
63
62
|
import { resolveSessionBusyVerdict } from './mesh-queue-assignment.js';
|
|
64
63
|
import { readLedgerEntries } from './mesh-ledger.js';
|
|
65
64
|
import type { MeshLedgerEntry } from './mesh-ledger.js';
|
|
66
|
-
import {
|
|
67
|
-
import { findTerminalLedgerEvidenceForTask, reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
|
|
68
|
-
import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
|
|
69
|
-
import type { ChatMessage } from '../types.js';
|
|
65
|
+
import { findTerminalLedgerEvidenceForTask } from './mesh-events-stale.js';
|
|
70
66
|
import {
|
|
71
67
|
resolveCoordinatorDaemonIds,
|
|
72
68
|
daemonHostsMesh,
|
|
73
|
-
daemonIdListIncludes,
|
|
74
69
|
resolveCoordinatorSelfIds,
|
|
75
70
|
} from './mesh-reconcile-identity.js';
|
|
76
71
|
import {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
resolveAutoPruneMinAgeMs,
|
|
73
|
+
resolvePendingHeldDrainEscalateMs,
|
|
74
|
+
resolveReconcileIntervalMs,
|
|
75
|
+
} from './mesh-reconcile-config.js';
|
|
76
|
+
import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
|
|
80
77
|
import {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
resolveAckedTranscriptFastTrackGraceMs,
|
|
85
|
-
inFlightSynthKey,
|
|
86
|
-
getHoldState,
|
|
87
|
-
setHoldState,
|
|
88
|
-
deleteHoldState,
|
|
89
|
-
rehydrateAckedHoldsForMesh,
|
|
90
|
-
collectHeldSynthKeysForMesh,
|
|
91
|
-
} from './mesh-reconcile-acked-hold.js';
|
|
78
|
+
reconcileUnterminatedDirectDispatches,
|
|
79
|
+
autoPruneStaleDirectDispatches,
|
|
80
|
+
} from './mesh-completion-synthesis.js';
|
|
92
81
|
|
|
93
82
|
// Re-export the extracted public API so existing importers (mesh-events.ts barrel;
|
|
94
83
|
// the reconcile-loop test suite) keep their `from './mesh-reconcile-loop.js'` paths.
|
|
95
84
|
export { getMeshV2BackstopCounters, __resetMeshV2BackstopCountersForTests } from './mesh-reconcile-v2-backstop.js';
|
|
96
85
|
export { __resetReconcileInFlightSynthDebounceForTests } from './mesh-reconcile-acked-hold.js';
|
|
97
86
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
// orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
|
|
104
|
-
// measured from its dispatch time. This protects against a node/session that is only
|
|
105
|
-
// *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
|
|
106
|
-
// instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
|
|
107
|
-
// operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
|
|
108
|
-
const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
|
|
109
|
-
|
|
110
|
-
function resolveAutoPruneMinAgeMs(): number {
|
|
111
|
-
const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
|
|
112
|
-
if (raw) {
|
|
113
|
-
const parsed = Number.parseInt(raw, 10);
|
|
114
|
-
// Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
|
|
115
|
-
// (prune the moment something blinks) or effectively disable it forever.
|
|
116
|
-
if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
|
|
117
|
-
}
|
|
118
|
-
return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// PTY-OVERTRUST-DRAIN (Defect B, fix B). Age-based escape for the
|
|
122
|
-
// `generating_no_idle_coordinator` hold. Fix A makes the drain predicate read the RAW
|
|
123
|
-
// adapter (mask-stripped), so the common mask-driven false-busy is gone. But a hold can
|
|
124
|
-
// still arise from a genuine status-source desync that fix A does not reach (e.g. the
|
|
125
|
-
// adapter raw itself momentarily reads generating while the coordinator is actually at a
|
|
126
|
-
// turn end). This is a TIME-BASED BACKSTOP: when a mesh's pending terminal events have
|
|
127
|
-
// been held this long, re-confirm the coordinator's RAW adapter idle on the tick and, if
|
|
128
|
-
// it is genuinely idle, drain ONCE. It NEVER injects into a genuinely-generating PTY —
|
|
129
|
-
// the re-confirmation gates on raw adapter idle, so the intentional removal of
|
|
130
|
-
// force-inject-into-generating (data-loss) is preserved. Default 12s = 3 reconcile ticks
|
|
131
|
-
// at the 4s cadence: long enough that a normal mid-turn settle is not pre-empted, short
|
|
132
|
-
// enough that a desync-stranded completion is not held for minutes. Env-tunable.
|
|
133
|
-
const DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS = 12_000;
|
|
134
|
-
|
|
135
|
-
function resolvePendingHeldDrainEscalateMs(): number {
|
|
136
|
-
// Floor 4s (one tick) so a mis-set env cannot make the escape race a normal settle;
|
|
137
|
-
// ceiling 5min so it cannot be disabled into a permanent strand.
|
|
138
|
-
return resolveTunedReconcileMs('MESH_PENDING_HELD_DRAIN_ESCALATE_MS', DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS, 4_000, 5 * 60_000);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function resolveReconcileIntervalMs(): number {
|
|
142
|
-
const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
|
|
143
|
-
if (raw) {
|
|
144
|
-
const parsed = Number.parseInt(raw, 10);
|
|
145
|
-
if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
|
|
146
|
-
}
|
|
147
|
-
return DEFAULT_RECONCILE_INTERVAL_MS;
|
|
148
|
-
}
|
|
87
|
+
// Reconcile-loop timing tunables + their env-override resolvers
|
|
88
|
+
// (DEFAULT_RECONCILE_INTERVAL_MS, DEFAULT_AUTO_PRUNE_MIN_AGE_MS,
|
|
89
|
+
// DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS, resolveReconcileIntervalMs,
|
|
90
|
+
// resolveAutoPruneMinAgeMs, resolvePendingHeldDrainEscalateMs) live in
|
|
91
|
+
// ./mesh-reconcile-config.ts (A-3 extraction) and are imported above.
|
|
149
92
|
|
|
150
93
|
interface LiveCoordinator {
|
|
151
94
|
meshId: string;
|
|
@@ -1498,607 +1441,11 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
|
|
|
1498
1441
|
}
|
|
1499
1442
|
}
|
|
1500
1443
|
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
// by coordinatorDaemonId — returning events targeted at that id OR unscoped, and
|
|
1507
|
-
// leaving events targeted at a *different* coordinator. A remote worker stamps the
|
|
1508
|
-
// coordinator id in one of SEVERAL forms (the canonical status id `standalone_`/
|
|
1509
|
-
// `daemon_<machineId>` stamped by the MCP layer, the bare machineId stamped by the
|
|
1510
|
-
// local queue path, OR — most commonly for remote launches — the coordinator mesh
|
|
1511
|
-
// node's config-form `daemonId`, which resolveCoordinatorDaemonId prefers and which
|
|
1512
|
-
// is NOT canonicalised). `candidateDaemonIds` is the already-expanded self-identity
|
|
1513
|
-
// set (resolveCoordinatorSelfIds: runtime drain ids ∪ this daemon's mesh-config node/
|
|
1514
|
-
// host id forms), so we pull ONCE PER candidate id and a completion stamped with any
|
|
1515
|
-
// of them is recovered. The remote drain is atomic (drained=1), so issuing multiple
|
|
1516
|
-
// pulls cannot double-deliver — the first pull that matches consumes the event; the
|
|
1517
|
-
// rest see nothing. When no ids resolve we fall back to a single unscoped pull.
|
|
1518
|
-
async function pullRemoteNodeQueues(
|
|
1519
|
-
components: DaemonComponents,
|
|
1520
|
-
mesh: LocalMeshEntry,
|
|
1521
|
-
localDaemonId: string | undefined,
|
|
1522
|
-
candidateDaemonIds: string[],
|
|
1523
|
-
): Promise<void> {
|
|
1524
|
-
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
1525
|
-
if (!dispatchMeshCommand) return;
|
|
1526
|
-
const meshId = mesh.id;
|
|
1527
|
-
|
|
1528
|
-
// One args object per candidate coordinator-id form, or a single unscoped pull
|
|
1529
|
-
// when none resolve.
|
|
1530
|
-
const pulls: Array<Record<string, unknown>> = candidateDaemonIds.length > 0
|
|
1531
|
-
? candidateDaemonIds.map(id => ({ meshId, coordinatorDaemonId: id }))
|
|
1532
|
-
: [{ meshId }];
|
|
1533
|
-
|
|
1534
|
-
// Parallelize across nodes: a single connected-but-slow node must not serially
|
|
1535
|
-
// block the other nodes for the rest of the tick. Each node callback is fully
|
|
1536
|
-
// self-contained (local/candidate skip, peer-connected pre-check, per-candidate
|
|
1537
|
-
// pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
|
|
1538
|
-
await Promise.allSettled(mesh.nodes.map(async (node) => {
|
|
1539
|
-
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
1540
|
-
// Skip nodes without a daemon, and nodes on THIS daemon (their events are
|
|
1541
|
-
// already in the local queue drained in PHASE 2). "This daemon" is matched
|
|
1542
|
-
// against the full self-identity set (candidateDaemonIds), not just the bare
|
|
1543
|
-
// localDaemonId — a self node can be registered under the config-form daemonId
|
|
1544
|
-
// (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
|
|
1545
|
-
// from ourselves over P2P is both wasteful and a self-dispatch hazard.
|
|
1546
|
-
if (!nodeDaemonId) return;
|
|
1547
|
-
if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
|
|
1548
|
-
if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
|
|
1549
|
-
|
|
1550
|
-
// Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): a degraded peer whose
|
|
1551
|
-
// DataChannel is not open would sink this pull into peer.connectQueue and stall
|
|
1552
|
-
// until CONNECT_TIMEOUT_MS (90s), formerly freezing the whole serial loop and
|
|
1553
|
-
// delaying completion-event recovery from healthy nodes. Skip such a node THIS
|
|
1554
|
-
// tick and retry next tick — LOSSLESS: an unconnected peer has not drained
|
|
1555
|
-
// anything (drained=0 preserved), so its events are recovered whole on the next
|
|
1556
|
-
// successful tick. Skip = delay, never loss.
|
|
1557
|
-
// • snapshot present and state !== 'connected' → skip (continue next tick).
|
|
1558
|
-
// • snapshot null/undefined (getter unwired, e.g. standalone) → DO NOT skip;
|
|
1559
|
-
// fall through to the legacy path so this stays regression-free.
|
|
1560
|
-
const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
|
|
1561
|
-
if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return;
|
|
1562
|
-
|
|
1563
|
-
for (const pendingEventArgs of pulls) {
|
|
1564
|
-
let events: unknown;
|
|
1565
|
-
try {
|
|
1566
|
-
events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
|
|
1567
|
-
} catch {
|
|
1568
|
-
// Remote pull is best-effort; the node may be offline. Retry next tick.
|
|
1569
|
-
break; // node unreachable — don't bother with the other id form this tick.
|
|
1570
|
-
}
|
|
1571
|
-
const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
|
|
1572
|
-
for (const event of list) {
|
|
1573
|
-
const payload = buildForwardPayloadFromPending(event);
|
|
1574
|
-
if (!payload.event || !payload.meshId) continue;
|
|
1575
|
-
try {
|
|
1576
|
-
handleMeshForwardEvent(components, payload);
|
|
1577
|
-
} catch { /* best-effort re-inject */ }
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
}));
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// Pull the read_chat payload out of whatever envelope the transport returned.
|
|
1584
|
-
// A local commandHandler.handle() returns the CommandResult directly; a remote
|
|
1585
|
-
// dispatchMeshCommand returns it possibly wrapped in { payload } / { result }.
|
|
1586
|
-
function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null {
|
|
1587
|
-
let cursor: unknown = raw;
|
|
1588
|
-
for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
|
|
1589
|
-
const record = cursor as Record<string, unknown>;
|
|
1590
|
-
if (Array.isArray(record.messages)) return record;
|
|
1591
|
-
if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
|
|
1592
|
-
if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
|
|
1593
|
-
if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
|
|
1594
|
-
break;
|
|
1595
|
-
}
|
|
1596
|
-
return cursor && typeof cursor === 'object' ? cursor as Record<string, unknown> : null;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
function readChatPayloadStatus(payload: Record<string, unknown> | null): string {
|
|
1600
|
-
return readNonEmptyString(payload?.status).toLowerCase();
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
// R4e fix (3): peek the pending-events queue for a REAL (worker-emitted) terminal completion
|
|
1604
|
-
// already queued for a task — used to yield the in-flight synth to the worker's own emit. Broad
|
|
1605
|
-
// peek (no daemon-id scoping) matched precisely by taskId, so a worker stamp in any daemon-id form
|
|
1606
|
-
// is still recognized. Best-effort: a peek failure returns false (proceed to synth — never block
|
|
1607
|
-
// delivery). A prior SYNTH's still-queued pending event also names this taskId, but a synth always
|
|
1608
|
-
// writes its terminal ledger atomically, so hasTerminalLedgerAfterDispatch downstream already
|
|
1609
|
-
// no-ops that case — this guard is specifically for an as-yet-unledgered worker emit in flight.
|
|
1610
|
-
function realTerminalEmitPendingForTask(meshId: string, taskId: string): boolean {
|
|
1611
|
-
let pending: readonly PendingMeshCoordinatorEvent[];
|
|
1612
|
-
try {
|
|
1613
|
-
pending = getPendingMeshCoordinatorEvents(meshId);
|
|
1614
|
-
} catch {
|
|
1615
|
-
return false;
|
|
1616
|
-
}
|
|
1617
|
-
return pending.some(e =>
|
|
1618
|
-
readNonEmptyString(e.metadataEvent?.taskId) === taskId
|
|
1619
|
-
&& (e.event === 'agent:generating_completed' || e.event === 'agent:stopped'));
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
// R4e fix (2): one fresh read_chat status read for the worker session, via the same local/remote
|
|
1623
|
-
// transport PHASE 4 uses. Returns the lowercased status, or null when the read is inconclusive
|
|
1624
|
-
// (transport error, success:false, no payload) — callers treat null as "no new evidence, proceed".
|
|
1625
|
-
async function reprobeWorkerStatus(
|
|
1626
|
-
components: DaemonComponents,
|
|
1627
|
-
args: { isLocalNode: boolean; nodeDaemonId: string; readArgs: Record<string, unknown> },
|
|
1628
|
-
): Promise<string | null> {
|
|
1629
|
-
try {
|
|
1630
|
-
if (args.isLocalNode) {
|
|
1631
|
-
const r = await components.commandHandler.handle('read_chat', args.readArgs);
|
|
1632
|
-
if (r && (r as { success?: boolean }).success === false) return null;
|
|
1633
|
-
return readChatPayloadStatus(unwrapReadChatPayload(r));
|
|
1634
|
-
}
|
|
1635
|
-
if (components.dispatchMeshCommand) {
|
|
1636
|
-
const r = await components.dispatchMeshCommand(args.nodeDaemonId, 'read_chat', args.readArgs);
|
|
1637
|
-
const p = unwrapReadChatPayload(r);
|
|
1638
|
-
if (p && (p as { success?: boolean }).success === false) return null;
|
|
1639
|
-
return readChatPayloadStatus(p);
|
|
1640
|
-
}
|
|
1641
|
-
} catch {
|
|
1642
|
-
return null;
|
|
1643
|
-
}
|
|
1644
|
-
return null;
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
// PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
|
|
1648
|
-
// hosts, confirm the worker session is idle via a read_chat and — if a final
|
|
1649
|
-
// assistant summary is present but no terminal ledger exists for that dispatch —
|
|
1650
|
-
// synthesize the missing completion through reconcileDirectDispatchCompletionFromTranscript.
|
|
1651
|
-
//
|
|
1652
|
-
// read_chat is resolved against the target node: a node on THIS daemon is read
|
|
1653
|
-
// through the local commandHandler; a remote node is read over P2P via
|
|
1654
|
-
// dispatchMeshCommand. Both yield the same { messages, status, providerSessionId }
|
|
1655
|
-
// shape. We only synthesize when the session reports idle AND a final assistant
|
|
1656
|
-
// message exists — the same evidence bar the MCP poll path uses — so an actively
|
|
1657
|
-
// generating worker is never falsely completed. The reconcile itself is idempotent.
|
|
1658
|
-
async function reconcileUnterminatedDirectDispatches(
|
|
1659
|
-
components: DaemonComponents,
|
|
1660
|
-
mesh: LocalMeshEntry,
|
|
1661
|
-
selfIds: string[],
|
|
1662
|
-
localDaemonId: string | undefined,
|
|
1663
|
-
): Promise<void> {
|
|
1664
|
-
const dispatches = getActiveDirectDispatches(mesh.id);
|
|
1665
|
-
|
|
1666
|
-
// T2 (B2b): restart rehydration. Reload this mesh's persisted acked-hold rows into
|
|
1667
|
-
// the Map cache the first time this process touches the mesh — a hold established
|
|
1668
|
-
// before a daemon restart is honored again. Must run BEFORE the prune below so a
|
|
1669
|
-
// rehydrated hold for a still-active task is not seen as absent-from-cache and lost.
|
|
1670
|
-
rehydrateAckedHoldsForMesh(mesh.id);
|
|
1671
|
-
|
|
1672
|
-
// Prune the in-flight acked-hold state to the tasks still active in THIS mesh, so a
|
|
1673
|
-
// completed/pruned task's state is dropped (both the Map cache AND the store row —
|
|
1674
|
-
// the persisted table never grows without bound). Runs even when there are zero
|
|
1675
|
-
// active dispatches so a restart that landed after every task terminated still
|
|
1676
|
-
// reaps orphaned store rows. Iterate the union of Map keys and store rows so a row
|
|
1677
|
-
// that exists ONLY on disk (not yet cached) is pruned too.
|
|
1678
|
-
const activeTaskKeys = new Set(
|
|
1679
|
-
dispatches
|
|
1680
|
-
.map(d => readNonEmptyString(d.taskId))
|
|
1681
|
-
.filter(Boolean)
|
|
1682
|
-
.map(taskId => inFlightSynthKey(mesh.id, taskId)),
|
|
1683
|
-
);
|
|
1684
|
-
const heldKeys = collectHeldSynthKeysForMesh(mesh.id);
|
|
1685
|
-
for (const key of heldKeys) {
|
|
1686
|
-
if (!activeTaskKeys.has(key)) deleteHoldState(key, mesh.id);
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
if (dispatches.length === 0) return; // nothing left to reconcile after the prune
|
|
1690
|
-
|
|
1691
|
-
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
1692
|
-
const nodeById = new Map(mesh.nodes.map(n => [n.id, n] as const));
|
|
1693
|
-
|
|
1694
|
-
for (const dispatch of dispatches) {
|
|
1695
|
-
const sessionId = readNonEmptyString(dispatch.sessionId);
|
|
1696
|
-
const nodeId = readNonEmptyString(dispatch.nodeId);
|
|
1697
|
-
const taskId = readNonEmptyString(dispatch.taskId);
|
|
1698
|
-
if (!sessionId || !nodeId || !taskId) continue;
|
|
1699
|
-
|
|
1700
|
-
const node = nodeById.get(nodeId);
|
|
1701
|
-
const nodeDaemonId = readNonEmptyString(node?.daemonId);
|
|
1702
|
-
// A node is local when it has no daemonId, names this daemon, or actually
|
|
1703
|
-
// has a live instance here. Anything else is reached over P2P.
|
|
1704
|
-
const isLocalNode = !nodeDaemonId
|
|
1705
|
-
|| daemonIdListIncludes(selfIds, nodeDaemonId)
|
|
1706
|
-
|| daemonIdsEquivalent(nodeDaemonId, localDaemonId)
|
|
1707
|
-
|| !!components.instanceManager.getInstance(sessionId);
|
|
1708
|
-
|
|
1709
|
-
const providerType = readNonEmptyString(dispatch.providerType);
|
|
1710
|
-
const readArgs: Record<string, unknown> = {
|
|
1711
|
-
sessionId,
|
|
1712
|
-
targetSessionId: sessionId,
|
|
1713
|
-
tailLimit: 10,
|
|
1714
|
-
...(node?.workspace ? { workspace: node.workspace } : {}),
|
|
1715
|
-
...(providerType ? { agentType: providerType, providerType } : {}),
|
|
1716
|
-
};
|
|
1717
|
-
|
|
1718
|
-
const synthKey = inFlightSynthKey(mesh.id, taskId);
|
|
1719
|
-
const isAcked = dispatch.status === 'acked';
|
|
1720
|
-
// T6: which last-resort backstop (if any) drove this synth. Set when the
|
|
1721
|
-
// acked-hold fast-track / death-deadline promotes the synth; the counter is
|
|
1722
|
-
// bumped only if the synth actually COMMITS (result.reconciled), so a
|
|
1723
|
-
// deferred/re-probed-away synth is not miscounted. A never-acked dispatch
|
|
1724
|
-
// that reaches the commit is a plain PHASE-4 transcript synthesis.
|
|
1725
|
-
let backstopKind: keyof ReturnType<typeof getMeshV2BackstopCounters> | undefined;
|
|
1726
|
-
|
|
1727
|
-
// R4f: read the worker session. A FAILED read (transport error / success:false / no payload)
|
|
1728
|
-
// is no longer silently swallowed for an acked task — it is the liveness side of the
|
|
1729
|
-
// death backstop (a). We classify the read result and route an acked failure into the
|
|
1730
|
-
// failure counter; a never-acked (or non-acked) failure keeps the old best-effort `continue`.
|
|
1731
|
-
let payload: Record<string, unknown> | null = null;
|
|
1732
|
-
let readFailed = false;
|
|
1733
|
-
try {
|
|
1734
|
-
if (isLocalNode) {
|
|
1735
|
-
const result = await components.commandHandler.handle('read_chat', readArgs);
|
|
1736
|
-
if (result && (result as { success?: boolean }).success === false) {
|
|
1737
|
-
readFailed = true;
|
|
1738
|
-
} else {
|
|
1739
|
-
payload = unwrapReadChatPayload(result);
|
|
1740
|
-
}
|
|
1741
|
-
} else if (dispatchMeshCommand) {
|
|
1742
|
-
const result = await dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
|
|
1743
|
-
payload = unwrapReadChatPayload(result);
|
|
1744
|
-
if (payload && (payload as { success?: boolean }).success === false) { payload = null; readFailed = true; }
|
|
1745
|
-
} else {
|
|
1746
|
-
continue; // remote node but no P2P transport — can't read; retry next tick (not a death signal)
|
|
1747
|
-
}
|
|
1748
|
-
} catch {
|
|
1749
|
-
readFailed = true; // session may be gone or node offline
|
|
1750
|
-
}
|
|
1751
|
-
if (!payload && !readFailed) continue; // null payload that wasn't a hard failure — retry next tick
|
|
1752
|
-
|
|
1753
|
-
if (readFailed || !payload) {
|
|
1754
|
-
// R4f backstop (a) — liveness failure. For a never-acked dispatch there is no in-flight
|
|
1755
|
-
// turn to protect, so a read failure is a transient probe blip → retry next tick (old
|
|
1756
|
-
// behavior). For an ACKED dispatch that we had previously confirmed live, a streak of
|
|
1757
|
-
// consecutive read failures means the worker session genuinely went away mid-turn and
|
|
1758
|
-
// will never emit its real completion — count it. The actual terminal cleanup of a
|
|
1759
|
-
// gone session is owned by PHASE 2.5 (stranded reclaim) / PHASE 5 (orphan prune); here
|
|
1760
|
-
// we only record the death observation and STOP holding so those nets can take over,
|
|
1761
|
-
// rather than pinning the row on an indefinite hold for a session that is already gone.
|
|
1762
|
-
if (isAcked) {
|
|
1763
|
-
const prior = getHoldState(synthKey, mesh.id);
|
|
1764
|
-
const failures = (prior?.consecutiveReadFailures ?? 0) + 1;
|
|
1765
|
-
const liveConfirmedSinceAck = prior?.liveConfirmedSinceAck ?? false;
|
|
1766
|
-
// A read failure breaks the idle-with-final-assistant run → reset the fast-track streak
|
|
1767
|
-
// (transcriptIdleSinceMs cleared by omission) so it must re-accumulate from scratch.
|
|
1768
|
-
setHoldState(synthKey, mesh.id, { liveConfirmedSinceAck, consecutiveReadFailures: failures });
|
|
1769
|
-
if (liveConfirmedSinceAck && failures >= ACKED_DEATH_CONSECUTIVE_READ_FAILURES) {
|
|
1770
|
-
LOG.warn('MeshReconcile', `Acked-hold death signal: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read_chat failed ${failures}x consecutively after a live-confirmed ack — worker session presumed gone mid-turn; releasing the indefinite synth hold to the stranded-reclaim / orphan-prune nets`);
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
continue; // no readable transcript this tick → cannot synth here; retry / let backstops act
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
// Read succeeded (a conclusive idle/generating status) → the session is reachable: reset the
|
|
1777
|
-
// failure streak and mark it live-confirmed-since-ack, so a LATER read failure is recognized
|
|
1778
|
-
// as a genuine liveness loss (backstop a) rather than a node that was never reachable. The
|
|
1779
|
-
// fast-track idle streak (transcriptIdleSinceMs) is PRESERVED across this reset — it is
|
|
1780
|
-
// managed below where the idle + final-assistant signal is actually evaluated.
|
|
1781
|
-
const priorHoldState = getHoldState(synthKey, mesh.id);
|
|
1782
|
-
setHoldState(synthKey, mesh.id, {
|
|
1783
|
-
liveConfirmedSinceAck: true,
|
|
1784
|
-
consecutiveReadFailures: 0,
|
|
1785
|
-
...(priorHoldState?.transcriptIdleSinceMs !== undefined ? { transcriptIdleSinceMs: priorHoldState.transcriptIdleSinceMs } : {}),
|
|
1786
|
-
});
|
|
1787
|
-
|
|
1788
|
-
// Only act on a session that has actually settled to idle. A generating /
|
|
1789
|
-
// waiting_approval session is mid-turn — synthesizing a completion now would
|
|
1790
|
-
// be wrong. (idle is the only status the MCP poll path reconciles too.)
|
|
1791
|
-
const nowMs = Date.now();
|
|
1792
|
-
if (readChatPayloadStatus(payload) !== 'idle') {
|
|
1793
|
-
// Not idle → the worker is genuinely mid-turn (a clear live signal). Keep the
|
|
1794
|
-
// live-confirmed flag set (above) but RESET the fast-track idle streak: a turn that
|
|
1795
|
-
// resumed generating proves the prior idle was a mid-turn blip, not a settled turn-end.
|
|
1796
|
-
setHoldState(synthKey, mesh.id, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
|
|
1797
|
-
continue;
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
// R4f GENERATING-BOUNDARY (acked-hold): a dispatch whose worker was OBSERVED to start
|
|
1801
|
-
// generating (the agent:generating_started ack flipped the row to 'acked') is ALIVE and
|
|
1802
|
-
// mid-turn — it WILL eventually emit a real terminal. An `idle` read here is therefore
|
|
1803
|
-
// presumed a TRANSIENT mid-turn window (a PTY inter-tool-call settle, or final text already
|
|
1804
|
-
// rendered while the lifecycle close lags), NOT a settled completion. We HOLD the synth
|
|
1805
|
-
// INDEFINITELY rather than racing the worker's (variable, unbounded) emit latency with a
|
|
1806
|
-
// finite timer — the failure mode of R4..R4e. This is safe: when the worker's real emit
|
|
1807
|
-
// lands it writes a terminal ledger, and reconcileDirectDispatchCompletionFromTranscript's
|
|
1808
|
-
// hasTerminalLedgerAfterDispatch makes any later synth an idempotent no-op, so the real emit
|
|
1809
|
-
// always wins no matter how late. The hold is released ONLY by the death backstops:
|
|
1810
|
-
// (a) consecutive read failures after a live-confirmed ack (handled above), or
|
|
1811
|
-
// (b) the absolute ACKED_DEATH_DEADLINE_MS since the ack — a notification-loss net set FAR
|
|
1812
|
-
// above any observed emit latency, so it catches a genuinely-wedged worker / lost emit
|
|
1813
|
-
// without racing a normal slow turn.
|
|
1814
|
-
// A never-acked dispatch (worker never started) is exempt — no in-flight generation to
|
|
1815
|
-
// pre-empt; it keeps the first-idle-tick synth, with the downstream grace + stale-summary
|
|
1816
|
-
// guards as its backstops.
|
|
1817
|
-
//
|
|
1818
|
-
// ACKED-HOLD-IDLE-OVERTRUST: the read is idle. Extract the final-assistant evidence NOW (the
|
|
1819
|
-
// same signal the synth below requires) so the fast-track can gate on idle-WITH-final-assistant
|
|
1820
|
-
// rather than bare idle. Only when a final visible assistant message is present do we treat
|
|
1821
|
-
// this tick as a candidate turn-end and accumulate the fast-track grace streak; a bare idle
|
|
1822
|
-
// with no assistant result is the worker still warming up and resets the streak.
|
|
1823
|
-
const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
|
|
1824
|
-
const evidence = extractFinalAssistantSummaryEvidence(messages);
|
|
1825
|
-
|
|
1826
|
-
if (isAcked) {
|
|
1827
|
-
const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
|
|
1828
|
-
const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
|
|
1829
|
-
const deathDeadlineMs = resolveAckedDeathDeadlineMs();
|
|
1830
|
-
|
|
1831
|
-
// ACKED-HOLD-IDLE-OVERTRUST fast-track. Maintain the continuous idle-with-final-assistant
|
|
1832
|
-
// streak. The streak starts (or continues) only while a final visible assistant message is
|
|
1833
|
-
// present; a tick with idle-but-no-assistant breaks it (the answer is not yet rendered).
|
|
1834
|
-
const holdState = getHoldState(synthKey, mesh.id);
|
|
1835
|
-
let fastTrackReady = false;
|
|
1836
|
-
if (evidence.finalSummary) {
|
|
1837
|
-
const idleSinceMs = holdState?.transcriptIdleSinceMs ?? nowMs;
|
|
1838
|
-
if (holdState && holdState.transcriptIdleSinceMs === undefined) {
|
|
1839
|
-
setHoldState(synthKey, mesh.id, { ...holdState, transcriptIdleSinceMs: idleSinceMs });
|
|
1840
|
-
}
|
|
1841
|
-
const fastTrackGraceMs = resolveAckedTranscriptFastTrackGraceMs();
|
|
1842
|
-
const idleHeldMs = nowMs - idleSinceMs;
|
|
1843
|
-
if (idleHeldMs >= fastTrackGraceMs) {
|
|
1844
|
-
fastTrackReady = true;
|
|
1845
|
-
backstopKind = 'ackedHoldFastTrackFired';
|
|
1846
|
-
LOG.info('MeshReconcile', `Acked-hold transcript fast-track: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle WITH a final assistant message for ${Math.round(idleHeldMs / 1000)}s continuous (grace ${Math.round(fastTrackGraceMs / 1000)}s) — promoting the synth ahead of the ${Math.round(deathDeadlineMs / 1000)}s death backstop; the worker's real emit was lost/late and a later one no-ops idempotently.`);
|
|
1847
|
-
}
|
|
1848
|
-
} else if (holdState?.transcriptIdleSinceMs !== undefined) {
|
|
1849
|
-
// Idle but no final assistant yet → not a turn-end; reset the streak.
|
|
1850
|
-
setHoldState(synthKey, mesh.id, { ...holdState, transcriptIdleSinceMs: undefined });
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
// Hold indefinitely UNLESS the fast-track grace was met OR the absolute death deadline is
|
|
1854
|
-
// reached. The fast-track is the new fast path in front of the (preserved) 8-min backstop.
|
|
1855
|
-
if (!fastTrackReady && sinceAckMs < deathDeadlineMs) {
|
|
1856
|
-
LOG.info('MeshReconcile', `Acked-hold: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle ${Number.isFinite(sinceAckMs) ? Math.round(sinceAckMs / 1000) + 's' : '∞'} since the generating_started ack — HOLDING synth (worker presumed alive; a later real emit is idempotent). Transcript fast-track promotes at ${Math.round(resolveAckedTranscriptFastTrackGraceMs() / 1000)}s continuous idle-with-final-assistant; death backstop at ${Math.round(deathDeadlineMs / 1000)}s or on consecutive read failures.`);
|
|
1857
|
-
continue;
|
|
1858
|
-
}
|
|
1859
|
-
if (!fastTrackReady) {
|
|
1860
|
-
backstopKind = 'ackedHoldDeathDeadlineFired';
|
|
1861
|
-
LOG.warn('MeshReconcile', `Acked-hold death deadline reached: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) still idle ${Math.round(sinceAckMs / 1000)}s after the ack (deadline ${Math.round(deathDeadlineMs / 1000)}s) — synthesizing the missing completion as a notification-loss net (a real emit, if it ever lands, no-ops idempotently).`);
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
// R4f (auxiliary, was R4e fix 3) — worker-emit priority. Secondary check: if the worker's
|
|
1866
|
-
// REAL terminal emit for this task has already arrived in the pending-events queue (queued
|
|
1867
|
-
// for delivery to the coordinator) but not yet written a terminal ledger, YIELD — let the
|
|
1868
|
-
// genuine emit surface rather than racing it with a synth that would win the taskId-anchored
|
|
1869
|
-
// fingerprint dedup and mask it. Under the R4f acked-hold this is now an auxiliary belt-and-
|
|
1870
|
-
// suspenders check (the indefinite hold already defers an acked synth); it still guards the
|
|
1871
|
-
// never-acked path and the post-death-deadline acked synth from racing an emit caught in
|
|
1872
|
-
// flight at synth-commit time.
|
|
1873
|
-
if (realTerminalEmitPendingForTask(mesh.id, taskId)) {
|
|
1874
|
-
deleteHoldState(synthKey, mesh.id);
|
|
1875
|
-
LOG.info('MeshReconcile', `Worker-emit priority: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) has a real terminal completion already queued — yielding synth to the worker's own emit`);
|
|
1876
|
-
continue;
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
|
|
1880
|
-
|
|
1881
|
-
// STALE-SUMMARY guard (modal-parked / reused-session misattribution): a direct
|
|
1882
|
-
// dispatch frequently reuses a session that already ran a PRIOR task. read_chat
|
|
1883
|
-
// returns the tail of the WHOLE session, so extractFinalAssistantSummaryEvidence
|
|
1884
|
-
// picks the latest user-facing assistant message — which, for a task that has
|
|
1885
|
-
// barely started (the session momentarily reads idle between turns), is the prior
|
|
1886
|
-
// task's final summary. The downstream reconcile proves the summary is after the
|
|
1887
|
-
// LEDGER task_dispatched entry; here we additionally have the AUTHORITATIVE per-task
|
|
1888
|
-
// dispatchedAt (the dispatch-store row, immune to ledger-ordering quirks), so when
|
|
1889
|
-
// the selected transcript message is provably BEFORE this task's own dispatch we
|
|
1890
|
-
// refuse it outright — it is a prior task's summary, not this task's output (the
|
|
1891
|
-
// 2843ms-duration stale-summary bug where task 2e3f501e copy-pasted 4eca2d9d's
|
|
1892
|
-
// summary). When the message carries no usable timestamp we do NOT block here: the
|
|
1893
|
-
// downstream reconcile already rejects a non-JSON summary it cannot prove is
|
|
1894
|
-
// post-dispatch (transcript_not_proven_after_dispatch), and a structured
|
|
1895
|
-
// final_summary_json is self-attributing — so a timeless provider is not
|
|
1896
|
-
// over-blocked while the provable-stale case is still caught.
|
|
1897
|
-
const dispatchedAtMs = Date.parse(readNonEmptyString(dispatch.dispatchedAt));
|
|
1898
|
-
const transcriptAtMs = Date.parse(evidence.transcriptMessageAt ?? '');
|
|
1899
|
-
if (Number.isFinite(dispatchedAtMs) && Number.isFinite(transcriptAtMs) && transcriptAtMs < dispatchedAtMs) {
|
|
1900
|
-
LOG.info('MeshReconcile', `Stale-summary guard: skipping transcript reconcile for task ${taskId} on node ${nodeId} (mesh ${mesh.id}) — final assistant message (${evidence.transcriptMessageAt}) predates this task's dispatch (${dispatch.dispatchedAt}); it is a prior task's summary`);
|
|
1901
|
-
traceMeshEventDrop('reconcile_stale_summary_before_dispatch', {
|
|
1902
|
-
taskId, sessionId, nodeId, meshId: mesh.id, event: 'agent:generating_completed',
|
|
1903
|
-
}, `transcriptAt=${evidence.transcriptMessageAt} < dispatchedAt=${dispatch.dispatchedAt}`);
|
|
1904
|
-
continue;
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
// R4f (auxiliary, was R4e fix 2) — live re-probe immediately before committing the synth. A
|
|
1908
|
-
// fresh read right now catches a worker that resumed generating since this tick's first read
|
|
1909
|
-
// so it is never falsely completed off a stale snapshot. Best-effort: an inconclusive
|
|
1910
|
-
// re-probe (transport error/null) falls through to the synth — we already hold a valid idle
|
|
1911
|
-
// read from the top of THIS tick, so a re-probe failure must not re-introduce a
|
|
1912
|
-
// notification-miss. Under the R4f acked-hold this matters mainly for the never-acked path
|
|
1913
|
-
// and the post-death-deadline acked synth (the indefinite hold already deferred a live acked
|
|
1914
|
-
// turn); it stays as a final live-state guard at synth-commit time.
|
|
1915
|
-
const reprobeStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
|
|
1916
|
-
if (reprobeStatus && reprobeStatus !== 'idle') {
|
|
1917
|
-
deleteHoldState(synthKey, mesh.id);
|
|
1918
|
-
LOG.info('MeshReconcile', `Live re-probe defer: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read '${reprobeStatus}' at synth-commit time — worker resumed generating; deferring synth to a later tick`);
|
|
1919
|
-
continue;
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
const providerSessionId = readNonEmptyString(payload.providerSessionId);
|
|
1923
|
-
const coordinatorDaemonId = selfIds.find(id => !!id);
|
|
1924
|
-
try {
|
|
1925
|
-
const result = reconcileDirectDispatchCompletionFromTranscript({
|
|
1926
|
-
meshId: mesh.id,
|
|
1927
|
-
nodeId,
|
|
1928
|
-
sessionId,
|
|
1929
|
-
providerType: providerType || undefined,
|
|
1930
|
-
providerSessionId: providerSessionId || undefined,
|
|
1931
|
-
taskId,
|
|
1932
|
-
finalSummary: evidence.finalSummary,
|
|
1933
|
-
...(evidence.transcriptMessageAt ? { transcriptMessageAt: evidence.transcriptMessageAt } : {}),
|
|
1934
|
-
...(coordinatorDaemonId ? { targetCoordinatorDaemonId: coordinatorDaemonId } : {}),
|
|
1935
|
-
source: 'daemon_reconcile_transcript_completion',
|
|
1936
|
-
});
|
|
1937
|
-
if (result.reconciled) {
|
|
1938
|
-
// T6: this synth actually committed → count the last-resort backstop fire.
|
|
1939
|
-
// An acked hold routes to the fast-track / death-deadline kind captured
|
|
1940
|
-
// above; a never-acked dispatch is a plain PHASE-4 transcript synthesis.
|
|
1941
|
-
// Under enforce, recordBackstopFire additionally WARNs (target = 0 fires).
|
|
1942
|
-
recordBackstopFire(backstopKind ?? 'phase4SynthesisFired', `task ${taskId} on node ${nodeId} (mesh ${mesh.id}), kind=${result.kind}`);
|
|
1943
|
-
LOG.info('MeshReconcile', `Synthesized missing completion (${result.kind}) for task ${taskId} on node ${nodeId} (mesh ${mesh.id})`);
|
|
1944
|
-
}
|
|
1945
|
-
} catch (e: any) {
|
|
1946
|
-
LOG.warn('MeshReconcile', `Transcript completion reconcile threw for task ${taskId}: ${e?.message || e}`);
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
// PHASE 5 helper. Build the live-node view (mesh.nodes decorated with each node's live
|
|
1952
|
-
// session list) and run the shared prune core in execute mode with the conservative age gate.
|
|
1953
|
-
//
|
|
1954
|
-
// Orphan detection needs the SAME live-session evidence the manual MCP prune uses: a node still
|
|
1955
|
-
// in mesh.nodes whose session list no longer contains the dispatched sessionId is "session not
|
|
1956
|
-
// present" (prunable); a node missing from mesh.nodes entirely is "node no longer in live mesh"
|
|
1957
|
-
// (prunable). We obtain live sessions per node via get_status_metadata — local nodes through the
|
|
1958
|
-
// local commandHandler, remote nodes over P2P (dispatchMeshCommand) — exactly the transports
|
|
1959
|
-
// PHASE 4 already uses. A node we cannot probe (offline) keeps an empty session list; combined
|
|
1960
|
-
// with the age gate that only matters once the orphan is genuinely old.
|
|
1961
|
-
//
|
|
1962
|
-
// O(1) fast exit: when there are no active direct dispatches at all there is nothing to prune,
|
|
1963
|
-
// so we skip the (per-node) status probes entirely — an idle mesh costs one indexed query.
|
|
1964
|
-
async function autoPruneStaleDirectDispatches(
|
|
1965
|
-
components: DaemonComponents,
|
|
1966
|
-
mesh: LocalMeshEntry,
|
|
1967
|
-
selfIds: string[],
|
|
1968
|
-
localDaemonId: string | undefined,
|
|
1969
|
-
minAgeMs: number,
|
|
1970
|
-
): Promise<void> {
|
|
1971
|
-
const directDispatches = getActiveDirectDispatches(mesh.id);
|
|
1972
|
-
if (directDispatches.length === 0) return; // nothing dispatched → nothing to prune
|
|
1973
|
-
|
|
1974
|
-
const liveNodes = await collectLiveNodesWithSessions(components, mesh, selfIds, localDaemonId);
|
|
1975
|
-
|
|
1976
|
-
const result = pruneStaleDirectDispatches({
|
|
1977
|
-
meshId: mesh.id,
|
|
1978
|
-
queue: getQueue(mesh.id),
|
|
1979
|
-
ledgerEntries: readLedgerEntries(mesh.id, { tail: 500 }),
|
|
1980
|
-
directDispatches,
|
|
1981
|
-
nodes: liveNodes,
|
|
1982
|
-
execute: true,
|
|
1983
|
-
minAgeMs,
|
|
1984
|
-
source: 'daemon_reconcile_auto_prune',
|
|
1985
|
-
});
|
|
1986
|
-
|
|
1987
|
-
// Log only when something was actually pruned — silence on the common no-op tick.
|
|
1988
|
-
if (result.prunedCount > 0) {
|
|
1989
|
-
LOG.info('MeshReconcile', `Auto-pruned ${result.prunedCount} orphaned direct dispatch record(s) for mesh ${mesh.id}`);
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
// Probe each node for its live session list (get_status_metadata) and return mesh.nodes
|
|
1994
|
-
// decorated with a `sessions` array — the shape buildMeshActiveWork / sessionStatusFromNodes
|
|
1995
|
-
// consume to decide whether a dispatched session is still present. Best-effort: an unreachable
|
|
1996
|
-
// node yields an empty session list rather than throwing.
|
|
1997
|
-
async function collectLiveNodesWithSessions(
|
|
1998
|
-
components: DaemonComponents,
|
|
1999
|
-
mesh: LocalMeshEntry,
|
|
2000
|
-
selfIds: string[],
|
|
2001
|
-
localDaemonId: string | undefined,
|
|
2002
|
-
): Promise<any[]> {
|
|
2003
|
-
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
2004
|
-
return Promise.all(mesh.nodes.map(async (node) => {
|
|
2005
|
-
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
2006
|
-
const isLocalNode = !nodeDaemonId
|
|
2007
|
-
|| daemonIdListIncludes(selfIds, nodeDaemonId)
|
|
2008
|
-
|| daemonIdsEquivalent(nodeDaemonId, localDaemonId);
|
|
2009
|
-
// Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): mirror pullRemoteNodeQueues.
|
|
2010
|
-
// Without this the 90s connect-deadline block re-enters via this Promise.all —
|
|
2011
|
-
// a degraded remote's get_status_metadata sinks into peer.connectQueue and stalls
|
|
2012
|
-
// the whole prune probe. Only call the remote when the peer is 'connected'; an
|
|
2013
|
-
// unconnected peer is left undecorated (empty session list), same as unreachable.
|
|
2014
|
-
// Getter unwired (null/undefined) → do NOT skip, fall through (regression-free).
|
|
2015
|
-
if (!isLocalNode) {
|
|
2016
|
-
const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
|
|
2017
|
-
if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return node;
|
|
2018
|
-
}
|
|
2019
|
-
let statusResult: unknown;
|
|
2020
|
-
try {
|
|
2021
|
-
if (isLocalNode) {
|
|
2022
|
-
statusResult = await components.commandHandler.handle('get_status_metadata', {});
|
|
2023
|
-
} else if (dispatchMeshCommand) {
|
|
2024
|
-
statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
|
|
2025
|
-
} else {
|
|
2026
|
-
return node; // remote node, no P2P transport — leave undecorated
|
|
2027
|
-
}
|
|
2028
|
-
} catch {
|
|
2029
|
-
return node; // unreachable — leave undecorated (empty session list)
|
|
2030
|
-
}
|
|
2031
|
-
const sessions = extractStatusMetadataSessions(statusResult);
|
|
2032
|
-
return sessions.length > 0 ? { ...node, sessions } : node;
|
|
2033
|
-
}));
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
// Pull the live session list out of a get_status_metadata result, tolerating the same
|
|
2037
|
-
// envelope shapes unwrapReadChatPayload handles (direct CommandResult or { payload }/{ result }).
|
|
2038
|
-
function extractStatusMetadataSessions(raw: unknown): any[] {
|
|
2039
|
-
let cursor: unknown = raw;
|
|
2040
|
-
for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
|
|
2041
|
-
const record = cursor as Record<string, unknown>;
|
|
2042
|
-
const status = record.status && typeof record.status === 'object' ? record.status as Record<string, unknown> : undefined;
|
|
2043
|
-
if (status && Array.isArray(status.sessions)) return status.sessions;
|
|
2044
|
-
if (Array.isArray(record.sessions)) return record.sessions;
|
|
2045
|
-
if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
|
|
2046
|
-
if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
|
|
2047
|
-
if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
|
|
2048
|
-
break;
|
|
2049
|
-
}
|
|
2050
|
-
return [];
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
function extractPendingEvents(raw: unknown): any[] {
|
|
2054
|
-
if (Array.isArray(raw)) return raw;
|
|
2055
|
-
if (raw && typeof raw === 'object') {
|
|
2056
|
-
const events = (raw as Record<string, unknown>).events;
|
|
2057
|
-
if (Array.isArray(events)) return events;
|
|
2058
|
-
}
|
|
2059
|
-
return [];
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// Flatten a queued PendingMeshCoordinatorEvent into the flat payload shape
|
|
2063
|
-
// handleMeshForwardEvent expects (mirrors the MCP buildMeshForwardPayloadFromPendingEvent).
|
|
2064
|
-
function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
|
|
2065
|
-
const metadata = event?.metadataEvent && typeof event.metadataEvent === 'object'
|
|
2066
|
-
? event.metadataEvent as Record<string, unknown>
|
|
2067
|
-
: {};
|
|
2068
|
-
return {
|
|
2069
|
-
event: readNonEmptyString(event?.event),
|
|
2070
|
-
meshId: readNonEmptyString(event?.meshId),
|
|
2071
|
-
nodeId: readNonEmptyString(event?.nodeId) || readNonEmptyString(metadata.meshNodeId),
|
|
2072
|
-
workspace: readNonEmptyString(event?.workspace) || readNonEmptyString(metadata.workspace),
|
|
2073
|
-
// Preserve the originating coordinator session id across the relay. It is normally
|
|
2074
|
-
// carried inside metadataEvent.meshCoordinatorSessionId (spread below), but pass the
|
|
2075
|
-
// top-level field through explicitly too so the handleMeshForwardEvent whitelist
|
|
2076
|
-
// recovers it regardless of which carrier the producing daemon used.
|
|
2077
|
-
...(readNonEmptyString(event?.targetCoordinatorSessionId)
|
|
2078
|
-
? { targetCoordinatorSessionId: readNonEmptyString(event.targetCoordinatorSessionId) }
|
|
2079
|
-
: {}),
|
|
2080
|
-
...metadata,
|
|
2081
|
-
// NOTIF-MISS (FIX 3): surface the dispatch task id at the TOP LEVEL so the relay's
|
|
2082
|
-
// received-stage trace (and buildRelayMetadataEvent) recovers it regardless of which
|
|
2083
|
-
// carrier the producing daemon used. The metadata spread above may carry the id only as
|
|
2084
|
-
// `meshActiveTaskId` (a worker provider event), leaving top-level `taskId` unset and the
|
|
2085
|
-
// received stage rendering `task=-`. Resolve both carriers into an explicit `taskId` so
|
|
2086
|
-
// dedup stays task-scoped end-to-end. Only set when a non-empty id exists (no clobber to
|
|
2087
|
-
// undefined when neither is present).
|
|
2088
|
-
...((): Record<string, unknown> => {
|
|
2089
|
-
const tid = readNonEmptyString(metadata.taskId) || readNonEmptyString(metadata.meshActiveTaskId);
|
|
2090
|
-
return tid ? { taskId: tid } : {};
|
|
2091
|
-
})(),
|
|
2092
|
-
// T4 (B3b): carry the v2 envelope (protocolVersion/eventId/scope/dispatchedBy/
|
|
2093
|
-
// intendedFor) across the P2P relay boundary at the TOP LEVEL. These live on the
|
|
2094
|
-
// pending event itself, not inside metadataEvent, so without this the remote pull
|
|
2095
|
-
// re-queue would re-stamp a fresh eventId — breaking cross-machine idempotency and
|
|
2096
|
-
// downgrading the relayed completion to v1 broadcast routing. Spread LAST so the
|
|
2097
|
-
// authoritative envelope always wins over any stale key the metadata spread carried.
|
|
2098
|
-
...serializeV2EnvelopeToWire(event as PendingMeshCoordinatorEvent),
|
|
2099
|
-
};
|
|
2100
|
-
}
|
|
2101
|
-
|
|
1444
|
+
// The remote P2P pull helpers (pullRemoteNodeQueues + payload/envelope utilities)
|
|
1445
|
+
// live in ./mesh-remote-event-pull.ts, and the PHASE-4 completion-synthesis /
|
|
1446
|
+
// PHASE-5 auto-prune (reconcileUnterminatedDirectDispatches,
|
|
1447
|
+
// autoPruneStaleDirectDispatches) live in ./mesh-completion-synthesis.ts
|
|
1448
|
+
// (A-3 extraction). Both are imported at the top of this file.
|
|
2102
1449
|
interface ReconcileLoopHandle {
|
|
2103
1450
|
stop(): void;
|
|
2104
1451
|
}
|