@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.
@@ -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, serializeV2EnvelopeToWire } from './mesh-events-pending.js';
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 { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
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 { pruneStaleDirectDispatches } from './mesh-active-work.js';
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
- getMeshV2BackstopCounters,
78
- recordBackstopFire,
79
- } from './mesh-reconcile-v2-backstop.js';
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
- ACKED_DEATH_CONSECUTIVE_READ_FAILURES,
82
- resolveTunedReconcileMs,
83
- resolveAckedDeathDeadlineMs,
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
- // Default reconcile cadence. approval/completion notifications to a live CLI
99
- // coordinator land within at most one interval. Overridable via env for tuning.
100
- const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
101
-
102
- // PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
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
- // Cloud-only: poll each remote worker node daemon for pending coordinator events
1502
- // and re-inject them locally via handleMeshForwardEvent (which re-queues +
1503
- // surfaces to the live coordinator on the next tick / immediately if idle).
1504
- //
1505
- // Scoping: the remote handler (get_pending_mesh_events) drains its queue filtered
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
  }