@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.
- package/dist/index.js +51 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +51 -30
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
- package/package.json +3 -3
- package/src/mesh/mesh-reconcile-loop.ts +43 -2
- package/src/mesh/mesh-remote-event-pull.ts +68 -47
|
@@ -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.
|
|
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.
|
|
51
|
-
"@adhdev/session-host-core": "1.0.18-rc.
|
|
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(
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|