@adhdev/daemon-core 0.9.82-rc.342 → 0.9.82-rc.344
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/commands/router.d.ts +20 -4
- package/dist/index.js +654 -332
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +674 -352
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +10 -0
- package/dist/mesh/mesh-events-utils.d.ts +10 -0
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +1 -0
- package/dist/mesh/mesh-work-queue.d.ts +10 -0
- package/dist/providers/cli-provider-instance.d.ts +1 -0
- package/dist/providers/provider-instance-manager.d.ts +1 -0
- package/dist/providers/provider-instance.d.ts +2 -0
- package/dist/providers/spec/fsm-driver.d.ts +16 -0
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +18 -0
- package/src/commands/router.ts +86 -8
- package/src/mesh/mesh-events-coordinator.ts +53 -3
- package/src/mesh/mesh-events-pending.ts +54 -4
- package/src/mesh/mesh-events-utils.ts +61 -9
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-reconcile-loop.ts +170 -3
- package/src/mesh/mesh-runtime-store.ts +38 -4
- package/src/mesh/mesh-work-queue.ts +13 -0
- package/src/providers/cli-provider-instance.ts +4 -1
- package/src/providers/provider-instance-manager.ts +1 -1
- package/src/providers/provider-instance.ts +1 -1
- package/src/providers/spec/fsm-driver.ts +64 -24
|
@@ -13,6 +13,16 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
13
13
|
* Absent on legacy events — treated as broadcast to any coordinator.
|
|
14
14
|
*/
|
|
15
15
|
targetCoordinatorDaemonId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* When set, this event is intended for a specific coordinator SESSION on the
|
|
18
|
+
* target daemon (the session that originally dispatched the work). PHASE 2 inject
|
|
19
|
+
* strict-matches the live coordinator by this session id so a sibling coordinator
|
|
20
|
+
* session on the same daemon does not receive another coordinator's completion.
|
|
21
|
+
* Absent on legacy / version-skewed events → daemon-level broadcast (no regression).
|
|
22
|
+
* Rides inside the event payload, so it survives the SQLite payload round-trip and
|
|
23
|
+
* the JSONL file without a dedicated column; it is NOT a drain-scoping key.
|
|
24
|
+
*/
|
|
25
|
+
targetCoordinatorSessionId?: string;
|
|
16
26
|
}
|
|
17
27
|
export declare function readRefineJobId(event: {
|
|
18
28
|
metadataEvent?: Record<string, unknown>;
|
|
@@ -15,6 +15,7 @@ export interface MeshWorkerRelayStamp {
|
|
|
15
15
|
meshNodeFor?: string;
|
|
16
16
|
meshNodeId?: string;
|
|
17
17
|
meshCoordinatorDaemonId?: string;
|
|
18
|
+
meshCoordinatorSessionId?: string;
|
|
18
19
|
launchedByCoordinator?: boolean;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
@@ -33,12 +34,21 @@ export declare function buildMeshWorkerRelayStamp(currentSettings: Record<string
|
|
|
33
34
|
meshId?: unknown;
|
|
34
35
|
nodeId?: unknown;
|
|
35
36
|
coordinatorDaemonId?: unknown;
|
|
37
|
+
coordinatorSessionId?: unknown;
|
|
36
38
|
} | undefined): MeshWorkerRelayStamp | undefined;
|
|
37
39
|
export declare function resolveEventSessionId(event: Record<string, unknown>, fallback?: unknown): string;
|
|
38
40
|
export declare function readRefineJobId(event: {
|
|
39
41
|
metadataEvent?: Record<string, unknown>;
|
|
40
42
|
} | Record<string, unknown>): string;
|
|
41
43
|
export declare function readWorkerResultMetadata(event: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The worker's final assistant text carried on a completion event — read from
|
|
46
|
+
* `finalSummary` (and the `workerResult.summary` / `result.summary` fallbacks some
|
|
47
|
+
* paths use). Returns '' when the event carries no assistant text (lifecycle events
|
|
48
|
+
* without a summary). Shared by the coordinator chat surface (buildMeshSystemMessage)
|
|
49
|
+
* and the held-event ledger audit record so both read the summary the same way.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readMeshCompletionSummary(metadataEvent: Record<string, unknown>): string;
|
|
42
52
|
/**
|
|
43
53
|
* A coordinator that surfaces a REMOTE worker's mesh session has no local instance for
|
|
44
54
|
* it, so the status snapshot's getLastDisplayMessage has nothing to read and the only
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
16
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
|
-
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned';
|
|
17
|
+
export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held';
|
|
18
18
|
export interface MeshLedgerEntry {
|
|
19
19
|
id: string;
|
|
20
20
|
meshId: string;
|
|
@@ -9,6 +9,7 @@ export declare class MeshRuntimeStore {
|
|
|
9
9
|
private static readonly WAL_CHECK_INTERVAL;
|
|
10
10
|
private static readonly WAL_MAX_BYTES;
|
|
11
11
|
private constructor();
|
|
12
|
+
private static loggedGetInstanceFailure;
|
|
12
13
|
static getInstance(): MeshRuntimeStore;
|
|
13
14
|
static resetForTests(): void;
|
|
14
15
|
close(): void;
|
|
@@ -71,6 +71,14 @@ export interface MeshWorkQueueEntry {
|
|
|
71
71
|
};
|
|
72
72
|
/** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
|
|
73
73
|
dispatchTimestamp?: string;
|
|
74
|
+
/**
|
|
75
|
+
* (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
|
|
76
|
+
* worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
|
|
77
|
+
* the exact coordinator session — even when several coordinator sessions share one
|
|
78
|
+
* daemon. Rides in the queue payload JSON (no column migration); absent on legacy rows
|
|
79
|
+
* → daemon-level routing fallback (backward + version-skew safe).
|
|
80
|
+
*/
|
|
81
|
+
sourceCoordinatorSessionId?: string;
|
|
74
82
|
createdAt: string;
|
|
75
83
|
updatedAt: string;
|
|
76
84
|
}
|
|
@@ -131,6 +139,8 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
|
|
|
131
139
|
missionId?: string;
|
|
132
140
|
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
133
141
|
id?: string;
|
|
142
|
+
/** (3) Originating coordinator session id (for session-anchored completion routing). */
|
|
143
|
+
sourceCoordinatorSessionId?: string;
|
|
134
144
|
} & MeshQueueMutationOptions): MeshWorkQueueEntry;
|
|
135
145
|
/**
|
|
136
146
|
* Record a direct-dispatch task (mesh_send_task) as an already-assigned queue
|
|
@@ -126,6 +126,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
126
126
|
nodeId?: string;
|
|
127
127
|
taskId?: string;
|
|
128
128
|
coordinatorDaemonId?: string;
|
|
129
|
+
coordinatorSessionId?: string;
|
|
129
130
|
}): void;
|
|
130
131
|
/**
|
|
131
132
|
* Clear a previously-attached mesh assignment after the task reaches a
|
|
@@ -91,6 +91,7 @@ export declare class ProviderInstanceManager {
|
|
|
91
91
|
nodeId?: string;
|
|
92
92
|
taskId?: string;
|
|
93
93
|
coordinatorDaemonId?: string;
|
|
94
|
+
coordinatorSessionId?: string;
|
|
94
95
|
}): boolean;
|
|
95
96
|
/** Clear a mesh assignment after the dispatched task reaches a terminal
|
|
96
97
|
* state (generating_completed / stopped / failed). */
|
|
@@ -188,6 +188,8 @@ export interface ProviderInstance {
|
|
|
188
188
|
meshId: string;
|
|
189
189
|
nodeId?: string;
|
|
190
190
|
taskId?: string;
|
|
191
|
+
coordinatorDaemonId?: string;
|
|
192
|
+
coordinatorSessionId?: string;
|
|
191
193
|
}): void;
|
|
192
194
|
detachMeshAssignment?(): void;
|
|
193
195
|
/** Refresh static provider definition/scripts without restarting the live runtime. */
|
|
@@ -177,6 +177,10 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
177
177
|
* after a re-prime we don't re-inject until the screen changes (which
|
|
178
178
|
* resets the stall reference) or another full stall window lapses. */
|
|
179
179
|
private lastRefocusAt;
|
|
180
|
+
/** Timer driving the win32 verification-based submit resend loop (see
|
|
181
|
+
* WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
|
|
182
|
+
* leaves idle (submitted) or the resend budget is spent. */
|
|
183
|
+
private win32SubmitTimer;
|
|
180
184
|
private currentEval;
|
|
181
185
|
private stateHistory;
|
|
182
186
|
private prevStateAt;
|
|
@@ -294,6 +298,18 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
294
298
|
private fireDelegate;
|
|
295
299
|
private handleSendMessage;
|
|
296
300
|
private actuallySendMessage;
|
|
301
|
+
/** The agent's current coarse status, derived from the FSM node we're in. */
|
|
302
|
+
private currentStatus;
|
|
303
|
+
/**
|
|
304
|
+
* win32 verification-based submit. Sends the submit key, waits a gap, and if
|
|
305
|
+
* the FSM is still 'idle' (the prompt did not submit — the CR was absorbed as
|
|
306
|
+
* a multiline-paste newline) resends, up to WIN32_SUBMIT_MAX_RESENDS. The
|
|
307
|
+
* first CR always fires (so a stale/edge status never suppresses the submit);
|
|
308
|
+
* subsequent resends are gated on still being idle, and stop the instant the
|
|
309
|
+
* agent leaves idle (submitted → generating / approval). This converges the
|
|
310
|
+
* nondeterministic multiline window without spamming Enter into the next turn.
|
|
311
|
+
*/
|
|
312
|
+
private scheduleWin32Submit;
|
|
297
313
|
private handleClickControl;
|
|
298
314
|
private handleClickModalButton;
|
|
299
315
|
private handleAttachImage;
|
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.344",
|
|
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.344",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -725,6 +725,24 @@ export class DaemonCliManager {
|
|
|
725
725
|
|
|
726
726
|
// Create UUID-based key (allows separate instances even for same type+dir)
|
|
727
727
|
const key = crypto.randomUUID();
|
|
728
|
+
|
|
729
|
+
// (3) Session-anchored mesh routing: when launching a mesh COORDINATOR session
|
|
730
|
+
// (settings.meshCoordinatorFor set), expose this session's OWN runtime id to its MCP
|
|
731
|
+
// server via env. The MCP server is spawned by the CLI as a child and inherits this
|
|
732
|
+
// env, so the MCP layer can stamp ADHDEV_COORDINATOR_SESSION_ID as the originating
|
|
733
|
+
// coordinator on every dispatch (→ MeshContext.coordinatorSessionId → worker
|
|
734
|
+
// meshCoordinatorSessionId → completion targetCoordinatorSessionId → strict route).
|
|
735
|
+
// `key` IS the instance id findLiveCoordinators matches on, so the stamp and the live
|
|
736
|
+
// session agree. Re-applied on every (re)launch, so it always reflects the current id;
|
|
737
|
+
// a stale value only survives if the CLI process outlives a daemon restart, in which
|
|
738
|
+
// case routing falls back to the daemon level (no wedge — see mesh-reconcile-loop).
|
|
739
|
+
{
|
|
740
|
+
const coordinatorMeshId = (options?.settingsOverride as Record<string, unknown> | undefined)?.meshCoordinatorFor;
|
|
741
|
+
if (typeof coordinatorMeshId === 'string' && coordinatorMeshId.trim()) {
|
|
742
|
+
options = { ...options, extraEnv: { ...(options?.extraEnv || {}), ADHDEV_COORDINATOR_SESSION_ID: key } };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
728
746
|
const sessionRegistry = this.deps.getSessionRegistry?.() || null;
|
|
729
747
|
|
|
730
748
|
// ─── ACP category handle ───
|
package/src/commands/router.ts
CHANGED
|
@@ -954,6 +954,55 @@ function readCachedInlineMeshActiveSessions(node: any): string[] {
|
|
|
954
954
|
return sessionId ? [sessionId] : [];
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
/**
|
|
958
|
+
* Wider session-ownership scan used ONLY by resolveRemoteMeshSessionOwnerDaemonId: collect
|
|
959
|
+
* EVERY session id a mesh node currently hosts. readCachedInlineMeshActiveSessions above only
|
|
960
|
+
* surfaces the node's single primary session (cachedStatus.activeSession) — other consumers
|
|
961
|
+
* depend on that one-session semantics, so it is left untouched. A worker hosting more than one
|
|
962
|
+
* session exposes its non-primary sessions only through the plural live-session arrays the
|
|
963
|
+
* coordinator carries: status.activeSessions / activeSessionDetails (built from live records on
|
|
964
|
+
* the aggregate snapshot, see get_mesh_status), or a worker's merged session report. A
|
|
965
|
+
* controlbar/modal command (invoke_provider_script / resolve_action / set_mode / …) targeting a
|
|
966
|
+
* non-primary remote session resolves its owner daemon only when those plural shapes are scanned
|
|
967
|
+
* too. Mirrors sessionStatusFromNodes' shape tolerance (mesh-active-work.ts): plural arrays of
|
|
968
|
+
* string ids OR objects keyed by id/sessionId/session_id/runtimeSessionId/instanceId, on both
|
|
969
|
+
* camelCase and snake_case, at the node root and under cachedStatus / lastProbe.
|
|
970
|
+
*/
|
|
971
|
+
function collectMeshNodeHostedSessionIds(node: any): Set<string> {
|
|
972
|
+
const ids = new Set<string>();
|
|
973
|
+
for (const id of readCachedInlineMeshActiveSessions(node)) ids.add(id);
|
|
974
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
975
|
+
for (const value of [
|
|
976
|
+
node?.activeSessions,
|
|
977
|
+
node?.active_sessions,
|
|
978
|
+
node?.activeSessionDetails,
|
|
979
|
+
node?.active_session_details,
|
|
980
|
+
node?.sessions,
|
|
981
|
+
node?.sessionDetails,
|
|
982
|
+
node?.session_details,
|
|
983
|
+
readObjectRecord(node?.lastProbe).sessions,
|
|
984
|
+
readObjectRecord(node?.last_probe).sessions,
|
|
985
|
+
cachedStatus.activeSessions,
|
|
986
|
+
cachedStatus.active_sessions,
|
|
987
|
+
cachedStatus.activeSessionDetails,
|
|
988
|
+
cachedStatus.active_session_details,
|
|
989
|
+
cachedStatus.sessions,
|
|
990
|
+
]) {
|
|
991
|
+
if (!Array.isArray(value)) continue;
|
|
992
|
+
for (const item of value) {
|
|
993
|
+
if (typeof item === 'string') {
|
|
994
|
+
const id = readStringValue(item);
|
|
995
|
+
if (id) ids.add(id);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
const record = readObjectRecord(item);
|
|
999
|
+
const id = readStringValue(record.id, record.sessionId, record.session_id, record.runtimeSessionId, record.instanceId);
|
|
1000
|
+
if (id) ids.add(id);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return ids;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
957
1006
|
/**
|
|
958
1007
|
* Resolve the owning-node attribution for a mesh node record so a coordinator can
|
|
959
1008
|
* stamp the TRUE owner onto a synthetic session entry instead of letting the
|
|
@@ -3825,20 +3874,31 @@ export class DaemonCommandRouter {
|
|
|
3825
3874
|
* controlbar commands do not — so the controlbar buttons appear to do nothing.
|
|
3826
3875
|
*
|
|
3827
3876
|
* Mirror the existing node-level remote-forward pattern (fast_forward_mesh_node etc.):
|
|
3828
|
-
* scan the
|
|
3829
|
-
*
|
|
3830
|
-
*
|
|
3831
|
-
*
|
|
3877
|
+
* scan the candidate mesh nodes for the one hosting the targetSessionId, and return its
|
|
3878
|
+
* daemonId when that daemonId is a remote daemon (i.e. not this coordinator's own
|
|
3879
|
+
* statusInstanceId). Returns undefined for a locally-hosted session (no forward — execute
|
|
3880
|
+
* locally as before) or when ownership can't be resolved.
|
|
3881
|
+
*
|
|
3882
|
+
* The candidate set spans BOTH the cached inline-mesh nodes and the live aggregate
|
|
3883
|
+
* mesh-status snapshots. The inline cache reliably carries only each node's single primary
|
|
3884
|
+
* session (cachedStatus.activeSession), so a worker hosting more than one session exposes its
|
|
3885
|
+
* non-primary sessions only on the aggregate snapshot nodes (status.activeSessions /
|
|
3886
|
+
* activeSessionDetails, built from live session records). collectMeshNodeHostedSessionIds does
|
|
3887
|
+
* the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
|
|
3888
|
+
* session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
|
|
3889
|
+
* other consumers depend on stay untouched.
|
|
3832
3890
|
*/
|
|
3833
3891
|
public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string): string | undefined {
|
|
3834
3892
|
const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
3835
3893
|
if (!trimmed) return undefined;
|
|
3836
3894
|
const selfDaemonId = this.deps.statusInstanceId;
|
|
3837
|
-
for (const node of this.
|
|
3838
|
-
|
|
3839
|
-
if (!nodeSessions.includes(trimmed)) continue;
|
|
3895
|
+
for (const node of this.collectMeshSessionOwnerCandidateNodes()) {
|
|
3896
|
+
if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
|
|
3840
3897
|
const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
|
|
3841
|
-
|
|
3898
|
+
// A matching node with no readable daemonId can't be attributed — keep scanning
|
|
3899
|
+
// the remaining candidates (e.g. the same session on an aggregate node that does
|
|
3900
|
+
// carry the daemonId) rather than bailing on the whole resolution.
|
|
3901
|
+
if (!nodeDaemonId) continue;
|
|
3842
3902
|
// Only forward to a genuinely remote daemon. When the owning node is this
|
|
3843
3903
|
// coordinator itself (locally hosted worker), fall through to local handling.
|
|
3844
3904
|
if (selfDaemonId && nodeDaemonId === selfDaemonId) return undefined;
|
|
@@ -3847,6 +3907,21 @@ export class DaemonCommandRouter {
|
|
|
3847
3907
|
return undefined;
|
|
3848
3908
|
}
|
|
3849
3909
|
|
|
3910
|
+
/**
|
|
3911
|
+
* Candidate nodes for remote-session owner resolution: the cached inline-mesh nodes (which
|
|
3912
|
+
* carry each node's primary session) plus the nodes from every cached aggregate mesh-status
|
|
3913
|
+
* snapshot (which carry each node's full live session list). getCachedInlineMeshNodes()
|
|
3914
|
+
* returns a fresh array, so appending the aggregate nodes never mutates cached state.
|
|
3915
|
+
*/
|
|
3916
|
+
private collectMeshSessionOwnerCandidateNodes(): any[] {
|
|
3917
|
+
const nodes: any[] = this.getCachedInlineMeshNodes();
|
|
3918
|
+
for (const cached of this.aggregateMeshStatusCache.values()) {
|
|
3919
|
+
const snapshotNodes = cached?.snapshot?.nodes;
|
|
3920
|
+
if (Array.isArray(snapshotNodes)) nodes.push(...snapshotNodes);
|
|
3921
|
+
}
|
|
3922
|
+
return nodes;
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3850
3925
|
public getCachedInlineMesh(meshId: string, inlineMesh?: unknown): any | undefined {
|
|
3851
3926
|
if (inlineMesh && typeof inlineMesh === 'object') {
|
|
3852
3927
|
return this.warmInlineMeshCache(meshId, inlineMesh);
|
|
@@ -6305,6 +6380,9 @@ export class DaemonCommandRouter {
|
|
|
6305
6380
|
meshId: dispatchMeshContext.meshId,
|
|
6306
6381
|
nodeId: dispatchMeshContext.nodeId,
|
|
6307
6382
|
coordinatorDaemonId: dispatchMeshContext.coordinatorDaemonId,
|
|
6383
|
+
// Session-level anchor: preserved across the P2P dispatch to a
|
|
6384
|
+
// remote worker so its completion echoes back to the right session.
|
|
6385
|
+
coordinatorSessionId: dispatchMeshContext.coordinatorSessionId,
|
|
6308
6386
|
},
|
|
6309
6387
|
);
|
|
6310
6388
|
if (stamp) inst.updateSettings(stamp);
|
|
@@ -282,6 +282,11 @@ export function tryAssignQueueTask(
|
|
|
282
282
|
if (node?.daemonId && components.dispatchMeshCommand) {
|
|
283
283
|
const isLocalNode = components.cliManager.adapters.has(sessionId);
|
|
284
284
|
if (!isLocalNode) {
|
|
285
|
+
const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
286
|
+
// (3) Originating coordinator session that enqueued this task — route its
|
|
287
|
+
// completion back to that exact session (multi-coordinator). Carried over P2P
|
|
288
|
+
// to the remote worker, which echoes it on its completion event.
|
|
289
|
+
const sourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId) || undefined;
|
|
285
290
|
const delivery = createSessionDelivery({
|
|
286
291
|
meshId,
|
|
287
292
|
nodeId,
|
|
@@ -291,8 +296,9 @@ export function tryAssignQueueTask(
|
|
|
291
296
|
kind: 'task',
|
|
292
297
|
message: task.message,
|
|
293
298
|
status: 'delivering',
|
|
299
|
+
...(sourceCoordinatorSessionId ? { sourceCoordinatorSessionId } : {}),
|
|
300
|
+
...(localDaemonIdForDispatch ? { sourceCoordinatorDaemonId: localDaemonIdForDispatch } : {}),
|
|
294
301
|
});
|
|
295
|
-
const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
296
302
|
components.dispatchMeshCommand(node.daemonId, 'agent_command', {
|
|
297
303
|
targetSessionId: sessionId,
|
|
298
304
|
cliType: providerType,
|
|
@@ -303,6 +309,7 @@ export function tryAssignQueueTask(
|
|
|
303
309
|
nodeId,
|
|
304
310
|
taskId: task.id,
|
|
305
311
|
...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
|
|
312
|
+
...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
|
|
306
313
|
},
|
|
307
314
|
}).then(() => {
|
|
308
315
|
updateSessionDeliveryStatus(delivery.id, 'delivered');
|
|
@@ -341,12 +348,16 @@ export function tryAssignQueueTask(
|
|
|
341
348
|
// the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
|
|
342
349
|
// the anchor the forwarder keys on), matching what mesh_launch_session stamps.
|
|
343
350
|
const localDaemonId = readNonEmptyString(loadConfig().machineId);
|
|
351
|
+
const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
|
|
344
352
|
inst.updateSettings({
|
|
345
353
|
meshNodeFor: meshId,
|
|
346
354
|
meshNodeId: nodeId,
|
|
347
355
|
launchedByCoordinator: true,
|
|
348
356
|
autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
|
|
349
357
|
...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
|
|
358
|
+
// (3) Stamp the originating coordinator session for session-anchored routing
|
|
359
|
+
// of this co-located worker's completion. Absent → daemon-level fallback.
|
|
360
|
+
...(localSourceCoordinatorSessionId ? { meshCoordinatorSessionId: localSourceCoordinatorSessionId } : {}),
|
|
350
361
|
});
|
|
351
362
|
}
|
|
352
363
|
} catch { /* best-effort — dispatch still proceeds */ }
|
|
@@ -360,6 +371,8 @@ export function tryAssignQueueTask(
|
|
|
360
371
|
kind: 'task',
|
|
361
372
|
message: task.message,
|
|
362
373
|
status: 'delivering',
|
|
374
|
+
...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
|
|
375
|
+
...(readNonEmptyString(loadConfig().machineId) ? { sourceCoordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
|
|
363
376
|
});
|
|
364
377
|
components.cliManager.handleCliCommand('agent_command', {
|
|
365
378
|
targetSessionId: sessionId,
|
|
@@ -369,9 +382,23 @@ export function tryAssignQueueTask(
|
|
|
369
382
|
}).then(() => {
|
|
370
383
|
updateSessionDeliveryStatus(delivery.id, 'delivered');
|
|
371
384
|
}).catch((e: any) => {
|
|
385
|
+
// Mirror the remote-dispatch catch above: a local dispatch failure is most often a
|
|
386
|
+
// transient busy/refusal (e.g. the adapter rejected send_chat while mid-generation),
|
|
387
|
+
// not a permanent task failure. Marking the task terminal 'failed' here with no ledger
|
|
388
|
+
// and no retry permanently killed tasks that a later tick would have delivered fine.
|
|
389
|
+
// Return the task to 'pending' and record a retryable dispatch_failed ledger entry so
|
|
390
|
+
// the reconcile loop re-dispatches it, exactly as the remote branch does.
|
|
372
391
|
LOG.error('MeshQueue', `Failed to dispatch task locally to node ${nodeId}: ${e?.message}`);
|
|
373
392
|
updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
|
|
374
|
-
updateTaskStatus(meshId, task.id, '
|
|
393
|
+
updateTaskStatus(meshId, task.id, 'pending');
|
|
394
|
+
try {
|
|
395
|
+
appendLedgerEntry(meshId, {
|
|
396
|
+
kind: 'dispatch_failed' as any,
|
|
397
|
+
nodeId,
|
|
398
|
+
sessionId,
|
|
399
|
+
payload: { taskId: task.id, deliveryId: delivery.id, error: e?.message, retryable: true },
|
|
400
|
+
});
|
|
401
|
+
} catch { /* ledger write is best-effort */ }
|
|
375
402
|
});
|
|
376
403
|
|
|
377
404
|
return true;
|
|
@@ -1388,6 +1415,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1388
1415
|
const workerCoordinatorDaemonId = readNonEmptyString(
|
|
1389
1416
|
(sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorDaemonId,
|
|
1390
1417
|
);
|
|
1418
|
+
// Session-level routing anchor (multi-coordinator). Prefer the LIVE worker session's
|
|
1419
|
+
// stamp; fall back to a relayed value carried in metadataEvent.meshCoordinatorSessionId
|
|
1420
|
+
// (a remote worker's completion arrives via handleMeshForwardEvent with no local
|
|
1421
|
+
// sourceSession, so the stamp can only ride in the relayed metadata). Empty on legacy /
|
|
1422
|
+
// version-skewed dispatches → the event stays daemon-broadcast (no regression).
|
|
1423
|
+
const workerCoordinatorSessionId = readNonEmptyString(
|
|
1424
|
+
(sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorSessionId,
|
|
1425
|
+
) || readNonEmptyString(args.metadataEvent.meshCoordinatorSessionId);
|
|
1391
1426
|
|
|
1392
1427
|
// R2: cloud P2P dashboard metadata sync. The cloud daemon used to do this from its own
|
|
1393
1428
|
// relay listener; now the single core forwarder invokes the injected hook (no-op on
|
|
@@ -1844,6 +1879,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1844
1879
|
metadataEvent: {
|
|
1845
1880
|
...args.metadataEvent,
|
|
1846
1881
|
...(recoveryContext ? { recoveryContext } : {}),
|
|
1882
|
+
// Stash the coordinator session id INSIDE metadataEvent too, so it survives the
|
|
1883
|
+
// P2P relay serialization (buildForwardPayloadFromPending spreads metadata; the
|
|
1884
|
+
// handleMeshForwardEvent whitelist reads it back) — a top-level field alone would
|
|
1885
|
+
// be dropped when the event crosses a machine boundary.
|
|
1886
|
+
...(workerCoordinatorSessionId ? { meshCoordinatorSessionId: workerCoordinatorSessionId } : {}),
|
|
1847
1887
|
},
|
|
1848
1888
|
// Silent lifecycle events (agent:ready / agent:generating_started) carry no
|
|
1849
1889
|
// coordinator message; they are queued only so the coordinator re-runs the
|
|
@@ -1852,9 +1892,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1852
1892
|
...(messageText ? { coordinatorMessage: messageText } : {}),
|
|
1853
1893
|
queuedAt: Date.now(),
|
|
1854
1894
|
...(workerCoordinatorDaemonId ? { targetCoordinatorDaemonId: workerCoordinatorDaemonId } : {}),
|
|
1895
|
+
// Top-level session anchor for the local PHASE 2 strict-match on the coordinator
|
|
1896
|
+
// daemon. Absent → daemon-level broadcast (legacy / single-coordinator path).
|
|
1897
|
+
...(workerCoordinatorSessionId ? { targetCoordinatorSessionId: workerCoordinatorSessionId } : {}),
|
|
1855
1898
|
};
|
|
1856
1899
|
if (queuePendingMeshCoordinatorEvent(pendingEvent)) {
|
|
1857
|
-
LOG.info('MeshEvents', `Queued ${args.event} for coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''})`);
|
|
1900
|
+
LOG.info('MeshEvents', `Queued ${args.event} for coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''}${workerCoordinatorSessionId ? `, coordinator session ${workerCoordinatorSessionId}` : ''})`);
|
|
1858
1901
|
}
|
|
1859
1902
|
return { success: true, forwarded: 0 };
|
|
1860
1903
|
}
|
|
@@ -1889,6 +1932,13 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1889
1932
|
targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
|
|
1890
1933
|
providerType: readNonEmptyString(payload.providerType),
|
|
1891
1934
|
providerSessionId: readNonEmptyString(payload.providerSessionId),
|
|
1935
|
+
// Preserve the originating coordinator SESSION id across the machine boundary so
|
|
1936
|
+
// the completion routes back to the exact coordinator session (multi-coordinator).
|
|
1937
|
+
// buildForwardPayloadFromPending spreads the worker event's metadata, so the id
|
|
1938
|
+
// arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
|
|
1939
|
+
// is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
|
|
1940
|
+
// anchors from this. Absent → daemon-level fallback (version-skew safe).
|
|
1941
|
+
meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
|
|
1892
1942
|
// Carry the session identity fields the worker provider event emits so the
|
|
1893
1943
|
// coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
|
|
1894
1944
|
// settings. Without these the remote-relay hop reconstructs metadataEvent with
|
|
@@ -2,9 +2,9 @@ import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkS
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { LOG } from '../logging/logger.js';
|
|
5
|
-
import { getLedgerDir, readLedgerEntries } from './mesh-ledger.js';
|
|
5
|
+
import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
|
|
6
6
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
7
|
-
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId } from './mesh-events-utils.js';
|
|
7
|
+
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary } from './mesh-events-utils.js';
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
|
|
@@ -33,6 +33,16 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
33
33
|
* Absent on legacy events — treated as broadcast to any coordinator.
|
|
34
34
|
*/
|
|
35
35
|
targetCoordinatorDaemonId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* When set, this event is intended for a specific coordinator SESSION on the
|
|
38
|
+
* target daemon (the session that originally dispatched the work). PHASE 2 inject
|
|
39
|
+
* strict-matches the live coordinator by this session id so a sibling coordinator
|
|
40
|
+
* session on the same daemon does not receive another coordinator's completion.
|
|
41
|
+
* Absent on legacy / version-skewed events → daemon-level broadcast (no regression).
|
|
42
|
+
* Rides inside the event payload, so it survives the SQLite payload round-trip and
|
|
43
|
+
* the JSONL file without a dedicated column; it is NOT a drain-scoping key.
|
|
44
|
+
*/
|
|
45
|
+
targetCoordinatorSessionId?: string;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
|
|
@@ -239,6 +249,42 @@ function trimPendingEventsIfNeeded(path: string): void {
|
|
|
239
249
|
if (statSync(path).size <= MAX_PENDING_EVENTS_BYTES) return;
|
|
240
250
|
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
241
251
|
if (lines.length <= MAX_PENDING_EVENTS_KEEP) return;
|
|
252
|
+
// C1 (data safety): this trim discards the OLDEST queued lines to keep the file
|
|
253
|
+
// bounded. An undelivered terminal completion among them would otherwise lose its
|
|
254
|
+
// worker summary silently (the JSONL is the only copy when the SQLite dual-write
|
|
255
|
+
// failed). Before dropping, mirror any meaningful (coordinator-facing / summary-
|
|
256
|
+
// bearing) dropped event into the ledger so it stays auditable and recoverable,
|
|
257
|
+
// and LOG.warn so the drop is observable instead of silent.
|
|
258
|
+
const dropped = lines.slice(0, lines.length - MAX_PENDING_EVENTS_KEEP);
|
|
259
|
+
for (const line of dropped) {
|
|
260
|
+
let event: PendingMeshCoordinatorEvent | undefined;
|
|
261
|
+
try { event = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { continue; }
|
|
262
|
+
if (!event || !event.meshId) continue;
|
|
263
|
+
const finalSummary = readMeshCompletionSummary(event.metadataEvent || {});
|
|
264
|
+
// "Meaningful" = would have been delivered to a coordinator (carries a message)
|
|
265
|
+
// or carries worker output worth preserving. Silent lifecycle events are not
|
|
266
|
+
// logged — losing them on trim is harmless (they re-drive nothing once stale).
|
|
267
|
+
if (!readNonEmptyString(event.coordinatorMessage) && !finalSummary) continue;
|
|
268
|
+
try {
|
|
269
|
+
appendLedgerEntry(event.meshId, {
|
|
270
|
+
kind: 'event_held',
|
|
271
|
+
...(event.nodeId ? { nodeId: event.nodeId } : {}),
|
|
272
|
+
payload: {
|
|
273
|
+
event: event.event,
|
|
274
|
+
reason: 'pending_trim_dropped',
|
|
275
|
+
recoverable: true,
|
|
276
|
+
nodeLabel: event.nodeLabel,
|
|
277
|
+
...(event.workspace ? { workspace: event.workspace } : {}),
|
|
278
|
+
targetCoordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
|
|
279
|
+
queuedAt: event.queuedAt,
|
|
280
|
+
...(finalSummary ? { finalSummary } : {}),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
LOG.warn('MeshEvents', `Pending-events trim dropping undelivered ${event.event} for mesh ${event.meshId} — recorded to ledger (recoverable)`);
|
|
284
|
+
} catch (e: any) {
|
|
285
|
+
LOG.warn('MeshEvents', `Failed to ledger-record trim-dropped ${event.event} for mesh ${event.meshId}: ${e?.message || e}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
242
288
|
writeFileSync(path, lines.slice(-MAX_PENDING_EVENTS_KEEP).join('\n') + '\n', 'utf-8');
|
|
243
289
|
} catch { /* best-effort; if trim fails, append still proceeds */ }
|
|
244
290
|
}
|
|
@@ -403,8 +449,12 @@ export function drainPendingMeshCoordinatorEvents(
|
|
|
403
449
|
if (event) pushUnique(event);
|
|
404
450
|
}
|
|
405
451
|
}
|
|
406
|
-
} catch {
|
|
407
|
-
// SQLite drain failed — JSONL below still drains
|
|
452
|
+
} catch (e: any) {
|
|
453
|
+
// SQLite drain failed — JSONL below still drains. Surface it: a silent
|
|
454
|
+
// failure here means the JSONL copy is emptied while the SQLite rows
|
|
455
|
+
// survive undrained, so the next drain re-delivers the same events to the
|
|
456
|
+
// coordinator (duplicate refine:completed etc.) with no diagnostic trail.
|
|
457
|
+
LOG.warn('MeshEvents', `SQLite pending-event drain failed for mesh ${meshId}; JSONL fallback only: ${e?.message || e}`);
|
|
408
458
|
}
|
|
409
459
|
|
|
410
460
|
// JSONL (legacy / migration path) — always drained alongside SQLite.
|