@adhdev/daemon-core 1.0.18-rc.13 → 1.0.18-rc.14

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.
@@ -1,6 +1,9 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
2
  import type { LocalMeshEntry } from '../repo-mesh-types.js';
3
3
  export declare function pullRemoteNodeQueues(components: DaemonComponents, mesh: LocalMeshEntry, localDaemonId: string | undefined, candidateDaemonIds: string[]): Promise<void>;
4
+ export declare function pullPendingEventsFromNode(components: DaemonComponents, meshId: string, node: {
5
+ daemonId?: string;
6
+ }, localDaemonId: string | undefined, candidateDaemonIds: string[], pulls: Array<Record<string, unknown>>): Promise<void>;
4
7
  export declare function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null;
5
8
  export declare function readChatPayloadStatus(payload: Record<string, unknown> | null): string;
6
9
  export declare function realTerminalEmitPendingForTask(meshId: string, taskId: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.13",
3
+ "version": "1.0.18-rc.14",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.13",
51
- "@adhdev/session-host-core": "1.0.18-rc.13",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.14",
51
+ "@adhdev/session-host-core": "1.0.18-rc.14",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -75,7 +75,7 @@ import {
75
75
  resolvePendingHeldDrainEscalateMs,
76
76
  resolveReconcileIntervalMs,
77
77
  } from './mesh-reconcile-config.js';
78
- import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
78
+ import { pullRemoteNodeQueues, pullPendingEventsFromNode } from './mesh-remote-event-pull.js';
79
79
  import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
80
80
  import {
81
81
  reconcileUnterminatedDirectDispatches,
@@ -781,6 +781,8 @@ async function recoverStrandedAssignedDispatches(
781
781
  components: DaemonComponents,
782
782
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
783
783
  store: MeshRuntimeStore,
784
+ localDaemonId?: string,
785
+ selfIds: string[] = [],
784
786
  ): Promise<void> {
785
787
  const meshId = mesh.id;
786
788
  const assigned = getQueue(meshId, { status: ['assigned'] });
@@ -832,6 +834,45 @@ async function recoverStrandedAssignedDispatches(
832
834
  updateTaskStatus(meshId, row.id, status);
833
835
  continue;
834
836
  }
837
+ // GENERATING-STARTED-CONSUME-RACE (fix B): the delivery-consume that clears this
838
+ // gate (delivered → acked) runs ONLY when the coordinator pulls the worker's queued
839
+ // agent:generating_started in PHASE 1 (handleMeshForwardEvent → consumeSessionDelivery).
840
+ // A worker may have emitted generating_started ALREADY, but if PHASE 1 has not yet
841
+ // pulled that specific event (a skipped/slow peer tick, or the event was queued after
842
+ // this tick's pull ran), the delivery row still reads 'delivered' here and the redrive
843
+ // below tears a genuinely-generating remote worker off its task and re-injects the
844
+ // prompt (the delivered_not_consumed_redrive symptom). Close the race by issuing a
845
+ // TARGETED, in-process last-chance pull of THIS node's queue right now, then
846
+ // re-reading taskDeliveryConsumed(): if the worker's generating_started was waiting to
847
+ // be pulled, the pull consumes the delivery this tick and we skip the redrive entirely.
848
+ // Best-effort and self-scoped (the same skip/peer guards as PHASE 1) — a miss just
849
+ // falls through to the existing UNKNOWN-streak grace, so this only ever removes false
850
+ // redrives, never adds one. A local (co-hosted) worker has nothing to pull (the node's
851
+ // daemon is a self id) and this is a fast no-op.
852
+ const assignedNode = row.assignedNodeId
853
+ ? mesh.nodes?.find(n => meshNodeIdMatches(n, row.assignedNodeId))
854
+ : undefined;
855
+ if (assignedNode?.daemonId) {
856
+ try {
857
+ await pullPendingEventsFromNode(
858
+ components,
859
+ meshId,
860
+ assignedNode,
861
+ localDaemonId,
862
+ selfIds,
863
+ selfIds.length > 0
864
+ ? selfIds.map(id => ({ meshId, coordinatorDaemonId: id }))
865
+ : [{ meshId }],
866
+ );
867
+ } catch { /* best-effort last-chance pull */ }
868
+ if (store.taskDeliveryConsumed(meshId, row.id)) {
869
+ // The pull delivered the worker's generating_started (or a terminal event) and
870
+ // consumed the delivery — the task is genuinely live/handled. Reset any accrued
871
+ // streak and leave the row to PHASE 4's completion reconcile.
872
+ deliveredUnconsumedUnknownStreak.delete(`${meshId}::${row.id}`);
873
+ continue;
874
+ }
875
+ }
835
876
  const shortStreakKey = `${meshId}::${row.id}`;
836
877
  const verdict = row.assignedSessionId
837
878
  ? resolveSessionBusyVerdict(components, row.assignedSessionId)
@@ -1227,7 +1268,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1227
1268
  const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1228
1269
  if (!daemonHostsMesh(mesh, selfIds)) continue;
1229
1270
  try {
1230
- await recoverStrandedAssignedDispatches(components, mesh, store);
1271
+ await recoverStrandedAssignedDispatches(components, mesh, store, localDaemonId, selfIds);
1231
1272
  } catch (e: any) {
1232
1273
  LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
1233
1274
  }
@@ -60,57 +60,78 @@ export async function pullRemoteNodeQueues(
60
60
  // block the other nodes for the rest of the tick. Each node callback is fully
61
61
  // self-contained (local/candidate skip, peer-connected pre-check, per-candidate
62
62
  // pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
63
- await Promise.allSettled(mesh.nodes.map(async (node) => {
64
- const nodeDaemonId = readNonEmptyString(node.daemonId);
65
- // Skip nodes without a daemon, and nodes on THIS daemon (their events are
66
- // already in the local queue drained in PHASE 2). "This daemon" is matched
67
- // against the full self-identity set (candidateDaemonIds), not just the bare
68
- // localDaemonId — a self node can be registered under the config-form daemonId
69
- // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
70
- // from ourselves over P2P is both wasteful and a self-dispatch hazard.
71
- if (!nodeDaemonId) return;
72
- if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
73
- if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
63
+ await Promise.allSettled(mesh.nodes.map(node =>
64
+ pullPendingEventsFromNode(components, meshId, node, localDaemonId, candidateDaemonIds, pulls)));
65
+ }
74
66
 
75
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
76
- // a degraded peer whose DataChannel is not open would sink this pull into
77
- // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
78
- // the whole serial loop and delaying completion-event recovery from healthy
79
- // nodes. Skip such a node THIS tick and retry next tick LOSSLESS: an
80
- // unconnected peer has not drained anything (drained=0 preserved), so its events
81
- // are recovered whole on the next successful tick. Skip = delay, never loss.
82
- // getter WIRED (cloud) a null/undefined snapshot means "no peer object
83
- // right now" = NOT connected (a powered-off node whose failPeer just deleted
84
- // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
85
- // dialing here would re-queue for another 90s (the null-race the guard is
86
- // meant to prevent). Only a snapshot with state === 'connected' proceeds.
87
- // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
88
- // so this stays regression-free (the standalone case the guard's history
89
- // references).
90
- const getPeerStatus = components.getMeshPeerConnectionStatus;
91
- if (getPeerStatus) {
92
- const peerSnapshot = getPeerStatus(nodeDaemonId);
93
- if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
94
- }
67
+ // Pull one node's pending coordinator events and re-inject them locally via
68
+ // handleMeshForwardEvent (which runs the delivery-consume + re-queue paths). Factored
69
+ // out of pullRemoteNodeQueues so the reconcile loop can also issue a TARGETED single-node
70
+ // pull on demand specifically, the DELIVERED-NOT-CONSUMED redrive gate calls this for
71
+ // one node right before it would re-drive, so a worker's already-emitted (but not-yet-
72
+ // pulled) agent:generating_started is consumed IN-PROCESS this tick instead of waiting for
73
+ // the next PHASE 1 pull. That closes the redrive-vs-consume race: the redrive gate reads
74
+ // taskDeliveryConsumed() AFTER this pull has had its chance to flip the delivery row.
75
+ // Returns silently on any skip/error every caller treats it as best-effort.
76
+ export async function pullPendingEventsFromNode(
77
+ components: DaemonComponents,
78
+ meshId: string,
79
+ node: { daemonId?: string },
80
+ localDaemonId: string | undefined,
81
+ candidateDaemonIds: string[],
82
+ pulls: Array<Record<string, unknown>>,
83
+ ): Promise<void> {
84
+ const dispatchMeshCommand = components.dispatchMeshCommand;
85
+ if (!dispatchMeshCommand) return;
86
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
87
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
88
+ // already in the local queue drained in PHASE 2). "This daemon" is matched
89
+ // against the full self-identity set (candidateDaemonIds), not just the bare
90
+ // localDaemonId — a self node can be registered under the config-form daemonId
91
+ // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
92
+ // from ourselves over P2P is both wasteful and a self-dispatch hazard.
93
+ if (!nodeDaemonId) return;
94
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
95
+ if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
95
96
 
96
- for (const pendingEventArgs of pulls) {
97
- let events: unknown;
97
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
98
+ // a degraded peer whose DataChannel is not open would sink this pull into
99
+ // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
100
+ // the whole serial loop and delaying completion-event recovery from healthy
101
+ // nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
102
+ // unconnected peer has not drained anything (drained=0 preserved), so its events
103
+ // are recovered whole on the next successful tick. Skip = delay, never loss.
104
+ // • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
105
+ // right now" = NOT connected (a powered-off node whose failPeer just deleted
106
+ // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
107
+ // dialing here would re-queue for another 90s (the null-race the guard is
108
+ // meant to prevent). Only a snapshot with state === 'connected' proceeds.
109
+ // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
110
+ // so this stays regression-free (the standalone case the guard's history
111
+ // references).
112
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
113
+ if (getPeerStatus) {
114
+ const peerSnapshot = getPeerStatus(nodeDaemonId);
115
+ if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
116
+ }
117
+
118
+ for (const pendingEventArgs of pulls) {
119
+ let events: unknown;
120
+ try {
121
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
122
+ } catch {
123
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
124
+ break; // node unreachable — don't bother with the other id form this tick.
125
+ }
126
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
127
+ for (const event of list) {
128
+ const payload = buildForwardPayloadFromPending(event);
129
+ if (!payload.event || !payload.meshId) continue;
98
130
  try {
99
- events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
100
- } catch {
101
- // Remote pull is best-effort; the node may be offline. Retry next tick.
102
- break; // node unreachable — don't bother with the other id form this tick.
103
- }
104
- const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
105
- for (const event of list) {
106
- const payload = buildForwardPayloadFromPending(event);
107
- if (!payload.event || !payload.meshId) continue;
108
- try {
109
- handleMeshForwardEvent(components, payload);
110
- } catch { /* best-effort re-inject */ }
111
- }
131
+ handleMeshForwardEvent(components, payload);
132
+ } catch { /* best-effort re-inject */ }
112
133
  }
113
- }));
134
+ }
114
135
  }
115
136
 
116
137
  // Pull the read_chat payload out of whatever envelope the transport returned.