@adhdev/daemon-core 0.9.82-rc.286 → 0.9.82-rc.288
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 +50 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +50 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/mesh/mesh-events-coordinator.ts +83 -8
- package/src/mesh/mesh-reconcile-loop.ts +67 -15
- package/src/mesh/mesh-routing.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.288",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.288",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -12,7 +12,7 @@ import { createSessionDelivery, markSessionDeliveriesTerminal, updateSessionDeli
|
|
|
12
12
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
13
13
|
import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
14
14
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
15
|
-
import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent } from './mesh-routing.js';
|
|
15
|
+
import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
|
|
16
16
|
import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
|
|
17
17
|
import {
|
|
18
18
|
findRecentTerminalLedgerEvidence,
|
|
@@ -1446,11 +1446,16 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1446
1446
|
if (!isMeshCoordinatorEvent(eventName)) {
|
|
1447
1447
|
return { success: false, error: 'unsupported mesh event' };
|
|
1448
1448
|
}
|
|
1449
|
-
const meshId = readNonEmptyString(payload.meshId);
|
|
1450
|
-
if (!meshId) return { success: false, error: 'meshId required' };
|
|
1451
|
-
|
|
1452
1449
|
const nodeId = readNonEmptyString(payload.nodeId);
|
|
1453
1450
|
const workspace = readNonEmptyString(payload.workspace);
|
|
1451
|
+
|
|
1452
|
+
// The fallback worker-forward path (forwardUnresolvedDelegateEvent) cannot resolve a
|
|
1453
|
+
// mesh id locally on the remote worker, so it forwards the event with workspace only.
|
|
1454
|
+
// The coordinator hosting the mesh CAN resolve it: recover the mesh id by workspace
|
|
1455
|
+
// when the payload doesn't carry one.
|
|
1456
|
+
const meshId = readNonEmptyString(payload.meshId)
|
|
1457
|
+
|| (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '');
|
|
1458
|
+
if (!meshId) return { success: false, error: 'meshId required' };
|
|
1454
1459
|
const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
|
|
1455
1460
|
const relayModalMessage = readNonEmptyString(payload.modalMessage);
|
|
1456
1461
|
const relayModalButtons = Array.isArray(payload.modalButtons)
|
|
@@ -1493,6 +1498,67 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1493
1498
|
});
|
|
1494
1499
|
}
|
|
1495
1500
|
|
|
1501
|
+
// ---------------------------------------------------------------------------
|
|
1502
|
+
// Worker-side fallback forward for unresolved-mesh delegates.
|
|
1503
|
+
//
|
|
1504
|
+
// A REMOTE worker daemon that is being P2P-remote-controlled by a coordinator is
|
|
1505
|
+
// NOT a member of the coordinator's mesh — it has no local mesh record. So when its
|
|
1506
|
+
// completion event reaches the forwarder, resolveWorkerDelegateRouting() resolves the
|
|
1507
|
+
// coordinator anchor (meshCoordinatorDaemonId) from the worker envelope but cannot
|
|
1508
|
+
// resolve the mesh id (neither meshNodeFor nor a workspace→mesh lookup yields one) and
|
|
1509
|
+
// returns isDelegate=false / mesh_unresolved. Before this fallback the event was dropped
|
|
1510
|
+
// (delivery_unroutable) and only recovered later when the coordinator happened to pull
|
|
1511
|
+
// the worker's queue — which it can't, because the worker never queued an unroutable
|
|
1512
|
+
// event. Live symptom: `WARN [MeshEvents] delivery_unroutable: ... mesh unresolved`.
|
|
1513
|
+
//
|
|
1514
|
+
// The fix: the routing object still carries coordinatorDaemonId. Forward the raw event
|
|
1515
|
+
// straight to that coordinator daemon over P2P (mesh_forward_event). The coordinator
|
|
1516
|
+
// hosts the mesh, so it recovers the mesh id by workspace in handleMeshForwardEvent and
|
|
1517
|
+
// injects/queues it normally. meshId is intentionally omitted from the payload (the
|
|
1518
|
+
// worker has none); workspace is the routing anchor the coordinator resolves from.
|
|
1519
|
+
//
|
|
1520
|
+
// No loop / no double-delivery:
|
|
1521
|
+
// - This only fires on the WORKER (the coordinator-own session is rejected by the
|
|
1522
|
+
// resolver before reaching here), and the coordinator merely injects — it does not
|
|
1523
|
+
// re-enter this forwarder for the relayed event.
|
|
1524
|
+
// - It fires only when the normal queue path did NOT run (isDelegate=false), so the
|
|
1525
|
+
// event is never both queued locally and forwarded.
|
|
1526
|
+
//
|
|
1527
|
+
// Returns true when the event was handed off to the coordinator daemon (so the caller
|
|
1528
|
+
// skips the delivery_unroutable diagnostic); false when no fallback was possible.
|
|
1529
|
+
function forwardUnresolvedDelegateEvent(
|
|
1530
|
+
components: DaemonComponents,
|
|
1531
|
+
routing: ReturnType<typeof resolveWorkerDelegateRouting>,
|
|
1532
|
+
event: Record<string, unknown>,
|
|
1533
|
+
): boolean {
|
|
1534
|
+
const coordinatorDaemonId = readNonEmptyString(routing.coordinatorDaemonId);
|
|
1535
|
+
if (!coordinatorDaemonId) return false;
|
|
1536
|
+
if (!components.dispatchMeshCommand) return false;
|
|
1537
|
+
|
|
1538
|
+
const eventName = readNonEmptyString(event.event);
|
|
1539
|
+
if (!eventName) return false;
|
|
1540
|
+
|
|
1541
|
+
// Flat payload mirroring buildForwardPayloadFromPending / what handleMeshForwardEvent
|
|
1542
|
+
// reads. meshId is omitted on purpose — the worker can't resolve it; the coordinator
|
|
1543
|
+
// recovers it from workspace. nodeId/workspace come from the worker envelope so the
|
|
1544
|
+
// coordinator can name and locate the node.
|
|
1545
|
+
const payload: Record<string, unknown> = {
|
|
1546
|
+
...event,
|
|
1547
|
+
event: eventName,
|
|
1548
|
+
nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId) || undefined,
|
|
1549
|
+
workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
Promise.resolve(components.dispatchMeshCommand(coordinatorDaemonId, 'mesh_forward_event', payload))
|
|
1553
|
+
.catch((e: any) => {
|
|
1554
|
+
// The coordinator may be momentarily unreachable; the diagnostic was already
|
|
1555
|
+
// skipped, so leave a trace here so an operator can see the relay attempt failed.
|
|
1556
|
+
LOG.warn('MeshEvents', `Fallback forward of ${eventName} to coordinator ${coordinatorDaemonId} failed: ${e?.message || e}`);
|
|
1557
|
+
});
|
|
1558
|
+
LOG.info('MeshEvents', `Fallback-forwarded ${eventName} for unresolved-mesh worker at ${routing.workspace || '(no workspace)'} to coordinator daemon ${coordinatorDaemonId}`);
|
|
1559
|
+
return true;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1496
1562
|
export function setupMeshEventForwarding(components: DaemonComponents) {
|
|
1497
1563
|
components.instanceManager.onEvent((event) => {
|
|
1498
1564
|
// --- Coordinator idle auto-flush (fast path) ---
|
|
@@ -1572,10 +1638,19 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
|
|
|
1572
1638
|
getMeshByWorkspace: (workspace) => getCachedMeshByWorkspace(workspace),
|
|
1573
1639
|
});
|
|
1574
1640
|
if (!routing.isDelegate) {
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1641
|
+
// Fallback: a REMOTE worker that isn't a member of the coordinator's mesh can't
|
|
1642
|
+
// resolve a mesh id locally (mesh_unresolved), but it still carries the coordinator
|
|
1643
|
+
// daemon anchor. Forward the event straight to that coordinator over P2P instead of
|
|
1644
|
+
// dropping it — the coordinator hosts the mesh and recovers the id by workspace.
|
|
1645
|
+
if (isUnroutableDelegateRejection(routing)
|
|
1646
|
+
&& forwardUnresolvedDelegateEvent(components, routing, event)) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
// R4: a worker that presented a valid envelope but resolved to no mesh (and could
|
|
1650
|
+
// not be fallback-forwarded — e.g. no coordinator anchor) used to be dropped
|
|
1651
|
+
// silently. Leave a fail-loud diagnostic so the missing completion is traceable.
|
|
1652
|
+
// Benign non-delegate rejections (not_cli / no_workspace / etc.) are no-ops inside
|
|
1653
|
+
// recordUnroutableDelegateEvent.
|
|
1579
1654
|
recordUnroutableDelegateEvent(routing, event.event);
|
|
1580
1655
|
return;
|
|
1581
1656
|
}
|
|
@@ -101,6 +101,13 @@ function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
|
|
|
101
101
|
// standalone-compat meshes with no host metadata) AND, when a hostDaemonId is
|
|
102
102
|
// pinned, it resolves to one of this daemon's ids. Member-only daemons return
|
|
103
103
|
// false — their own queue is pulled BY the host, not the other way around.
|
|
104
|
+
//
|
|
105
|
+
// `daemonIds` here is the EXPANDED self-identity set (runtime drain ids ∪ this
|
|
106
|
+
// daemon's mesh-config node id forms) — see resolveCoordinatorSelfIds. The
|
|
107
|
+
// pinned hostDaemonId is itself a config-form id and frequently does NOT equal a
|
|
108
|
+
// runtime id (bare machineId / status id), so gating on the runtime ids alone
|
|
109
|
+
// would wrongly classify the real host as a non-host and skip the remote pull
|
|
110
|
+
// entirely.
|
|
104
111
|
function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean {
|
|
105
112
|
const host = mesh.meshHost;
|
|
106
113
|
// No metadata → default host (standalone compatibility, see createDefaultMeshHostMetadata).
|
|
@@ -112,6 +119,39 @@ function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean {
|
|
|
112
119
|
return daemonIds.includes(hostDaemonId);
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
// Resolve EVERY id-form this daemon answers to FOR A GIVEN MESH: the runtime drain
|
|
123
|
+
// ids (status id + bare machineId) unioned with this daemon's mesh-config identity
|
|
124
|
+
// forms — the self node's daemonId/machineId (the node whose daemonId/machineId
|
|
125
|
+
// matches a runtime id) and the pinned meshHost.hostDaemonId WHEN it is provably
|
|
126
|
+
// ours. This is the single source of truth for "is this id me?" across both the
|
|
127
|
+
// host gate and the remote pull filter; the worker's meshCoordinatorDaemonId stamp
|
|
128
|
+
// is guaranteed to be one of these forms (it comes from resolveCoordinatorDaemonId,
|
|
129
|
+
// which prefers the coordinator node's config-form daemonId over the runtime status id).
|
|
130
|
+
function resolveCoordinatorSelfIds(mesh: LocalMeshEntry, drainDaemonIds: string[]): string[] {
|
|
131
|
+
const ids = new Set<string>(drainDaemonIds);
|
|
132
|
+
// Expand with the config-form id(s) of the self node — the mesh node whose
|
|
133
|
+
// daemonId/machineId matches a runtime id. Its config-form daemonId is exactly
|
|
134
|
+
// what resolveCoordinatorNode()→resolveCoordinatorDaemonId() stamps onto a worker.
|
|
135
|
+
for (const node of mesh.nodes) {
|
|
136
|
+
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
137
|
+
const nodeMachineId = readNonEmptyString(node.machineId);
|
|
138
|
+
const isSelf = (nodeDaemonId && drainDaemonIds.includes(nodeDaemonId))
|
|
139
|
+
|| (nodeMachineId && drainDaemonIds.includes(nodeMachineId));
|
|
140
|
+
if (!isSelf) continue;
|
|
141
|
+
if (nodeDaemonId) ids.add(nodeDaemonId);
|
|
142
|
+
if (nodeMachineId) ids.add(nodeMachineId);
|
|
143
|
+
}
|
|
144
|
+
// The pinned host id is included ONLY when it is provably one of THIS daemon's ids
|
|
145
|
+
// (it already matches a runtime id or a resolved self-node id). A hostDaemonId that
|
|
146
|
+
// names a DIFFERENT daemon must NOT be claimed — that would make a member-only
|
|
147
|
+
// daemon believe it is the host and pull queues it does not own. Having a node on
|
|
148
|
+
// this daemon does not make this daemon the host; daemonHostsMesh still honours a
|
|
149
|
+
// foreign hostDaemonId and rejects ownership.
|
|
150
|
+
const hostDaemonId = readNonEmptyString(mesh.meshHost?.hostDaemonId);
|
|
151
|
+
if (hostDaemonId && ids.has(hostDaemonId)) ids.add(hostDaemonId);
|
|
152
|
+
return [...ids];
|
|
153
|
+
}
|
|
154
|
+
|
|
115
155
|
// Find live CLI coordinator instances on THIS daemon, keyed by mesh.
|
|
116
156
|
function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
117
157
|
const out: LiveCoordinator[] = [];
|
|
@@ -179,9 +219,13 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
179
219
|
// remote worker's completion.
|
|
180
220
|
if (dispatchMeshCommand) {
|
|
181
221
|
for (const mesh of listMeshes()) {
|
|
182
|
-
|
|
222
|
+
// Expand to every id-form this daemon answers to for this mesh (runtime
|
|
223
|
+
// drain ids ∪ config-form node/host ids) and use it for BOTH the host gate
|
|
224
|
+
// and the remote pull filter, so a worker stamp in any form is recovered.
|
|
225
|
+
const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
|
|
226
|
+
if (!daemonHostsMesh(mesh, selfIds)) continue;
|
|
183
227
|
try {
|
|
184
|
-
await pullRemoteNodeQueues(components, mesh, localDaemonId,
|
|
228
|
+
await pullRemoteNodeQueues(components, mesh, localDaemonId, selfIds);
|
|
185
229
|
} catch (e: any) {
|
|
186
230
|
LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${mesh.id}: ${e?.message || e}`);
|
|
187
231
|
}
|
|
@@ -258,35 +302,43 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
258
302
|
// Scoping: the remote handler (get_pending_mesh_events) drains its queue filtered
|
|
259
303
|
// by coordinatorDaemonId — returning events targeted at that id OR unscoped, and
|
|
260
304
|
// leaving events targeted at a *different* coordinator. A remote worker stamps the
|
|
261
|
-
// coordinator id in one of
|
|
262
|
-
// `daemon_<machineId>` stamped by the MCP layer,
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
305
|
+
// coordinator id in one of SEVERAL forms (the canonical status id `standalone_`/
|
|
306
|
+
// `daemon_<machineId>` stamped by the MCP layer, the bare machineId stamped by the
|
|
307
|
+
// local queue path, OR — most commonly for remote launches — the coordinator mesh
|
|
308
|
+
// node's config-form `daemonId`, which resolveCoordinatorDaemonId prefers and which
|
|
309
|
+
// is NOT canonicalised). `candidateDaemonIds` is the already-expanded self-identity
|
|
310
|
+
// set (resolveCoordinatorSelfIds: runtime drain ids ∪ this daemon's mesh-config node/
|
|
311
|
+
// host id forms), so we pull ONCE PER candidate id and a completion stamped with any
|
|
312
|
+
// of them is recovered. The remote drain is atomic (drained=1), so issuing multiple
|
|
313
|
+
// pulls cannot double-deliver — the first pull that matches consumes the event; the
|
|
314
|
+
// rest see nothing. When no ids resolve we fall back to a single unscoped pull.
|
|
268
315
|
async function pullRemoteNodeQueues(
|
|
269
316
|
components: DaemonComponents,
|
|
270
317
|
mesh: LocalMeshEntry,
|
|
271
318
|
localDaemonId: string | undefined,
|
|
272
|
-
|
|
319
|
+
candidateDaemonIds: string[],
|
|
273
320
|
): Promise<void> {
|
|
274
321
|
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
275
322
|
if (!dispatchMeshCommand) return;
|
|
276
323
|
const meshId = mesh.id;
|
|
277
324
|
|
|
278
|
-
// One args object per coordinator-id form
|
|
279
|
-
//
|
|
280
|
-
const pulls: Array<Record<string, unknown>> =
|
|
281
|
-
?
|
|
325
|
+
// One args object per candidate coordinator-id form, or a single unscoped pull
|
|
326
|
+
// when none resolve.
|
|
327
|
+
const pulls: Array<Record<string, unknown>> = candidateDaemonIds.length > 0
|
|
328
|
+
? candidateDaemonIds.map(id => ({ meshId, coordinatorDaemonId: id }))
|
|
282
329
|
: [{ meshId }];
|
|
283
330
|
|
|
284
331
|
for (const node of mesh.nodes) {
|
|
285
332
|
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
286
333
|
// Skip nodes without a daemon, and nodes on THIS daemon (their events are
|
|
287
|
-
// already in the local queue drained in PHASE 2).
|
|
334
|
+
// already in the local queue drained in PHASE 2). "This daemon" is matched
|
|
335
|
+
// against the full self-identity set (candidateDaemonIds), not just the bare
|
|
336
|
+
// localDaemonId — a self node can be registered under the config-form daemonId
|
|
337
|
+
// (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
|
|
338
|
+
// from ourselves over P2P is both wasteful and a self-dispatch hazard.
|
|
288
339
|
if (!nodeDaemonId) continue;
|
|
289
340
|
if (localDaemonId && nodeDaemonId === localDaemonId) continue;
|
|
341
|
+
if (candidateDaemonIds.includes(nodeDaemonId)) continue;
|
|
290
342
|
|
|
291
343
|
for (const pendingEventArgs of pulls) {
|
|
292
344
|
let events: unknown;
|
package/src/mesh/mesh-routing.ts
CHANGED
|
@@ -82,10 +82,13 @@ export function resolveWorkerDelegateRouting(
|
|
|
82
82
|
const sessionId = readNonEmptyString(instanceId);
|
|
83
83
|
let workspace = '';
|
|
84
84
|
let coordinatorDaemonId = '';
|
|
85
|
+
// Runtime node-id stamp, surfaced even on rejection so the unresolved-mesh fallback
|
|
86
|
+
// forward can name the worker node for the coordinator.
|
|
87
|
+
let runtimeNodeId = '';
|
|
85
88
|
const reject = (rejectionReason: WorkerDelegateRejectionReason): WorkerDelegateRouting => ({
|
|
86
89
|
isDelegate: false,
|
|
87
90
|
meshId: '',
|
|
88
|
-
nodeId:
|
|
91
|
+
nodeId: runtimeNodeId,
|
|
89
92
|
nodeLabel: '',
|
|
90
93
|
coordinatorDaemonId,
|
|
91
94
|
workspace,
|
|
@@ -102,6 +105,7 @@ export function resolveWorkerDelegateRouting(
|
|
|
102
105
|
|
|
103
106
|
const settings = readSettings(state);
|
|
104
107
|
coordinatorDaemonId = readNonEmptyString(settings.meshCoordinatorDaemonId);
|
|
108
|
+
runtimeNodeId = readNonEmptyString(settings.meshNodeId);
|
|
105
109
|
|
|
106
110
|
// A coordinator session (meshCoordinatorFor set) is only treated as a worker delegate
|
|
107
111
|
// when it is itself the target of an active direct dispatch — otherwise its own events
|
|
@@ -138,7 +142,6 @@ export function resolveWorkerDelegateRouting(
|
|
|
138
142
|
if (!meshId) return reject('mesh_unresolved');
|
|
139
143
|
|
|
140
144
|
const targetNode = mesh?.nodes?.find((n: any) => n.workspace === workspace);
|
|
141
|
-
const runtimeNodeId = readNonEmptyString(settings.meshNodeId);
|
|
142
145
|
const nodeId = readNonEmptyString(targetNode?.id) || runtimeNodeId;
|
|
143
146
|
const nodeLabel = targetNode
|
|
144
147
|
? `Node '${targetNode.id}'`
|