@adhdev/daemon-core 0.9.82-rc.343 → 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 +599 -324
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +619 -344
- 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/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 +38 -2
- 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
|
@@ -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. */
|
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,
|
|
@@ -1402,6 +1415,14 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1402
1415
|
const workerCoordinatorDaemonId = readNonEmptyString(
|
|
1403
1416
|
(sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorDaemonId,
|
|
1404
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);
|
|
1405
1426
|
|
|
1406
1427
|
// R2: cloud P2P dashboard metadata sync. The cloud daemon used to do this from its own
|
|
1407
1428
|
// relay listener; now the single core forwarder invokes the injected hook (no-op on
|
|
@@ -1858,6 +1879,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1858
1879
|
metadataEvent: {
|
|
1859
1880
|
...args.metadataEvent,
|
|
1860
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 } : {}),
|
|
1861
1887
|
},
|
|
1862
1888
|
// Silent lifecycle events (agent:ready / agent:generating_started) carry no
|
|
1863
1889
|
// coordinator message; they are queued only so the coordinator re-runs the
|
|
@@ -1866,9 +1892,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1866
1892
|
...(messageText ? { coordinatorMessage: messageText } : {}),
|
|
1867
1893
|
queuedAt: Date.now(),
|
|
1868
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 } : {}),
|
|
1869
1898
|
};
|
|
1870
1899
|
if (queuePendingMeshCoordinatorEvent(pendingEvent)) {
|
|
1871
|
-
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}` : ''})`);
|
|
1872
1901
|
}
|
|
1873
1902
|
return { success: true, forwarded: 0 };
|
|
1874
1903
|
}
|
|
@@ -1903,6 +1932,13 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1903
1932
|
targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
|
|
1904
1933
|
providerType: readNonEmptyString(payload.providerType),
|
|
1905
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),
|
|
1906
1942
|
// Carry the session identity fields the worker provider event emits so the
|
|
1907
1943
|
// coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
|
|
1908
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.
|
|
@@ -42,6 +42,12 @@ export interface MeshWorkerRelayStamp {
|
|
|
42
42
|
meshNodeFor?: string;
|
|
43
43
|
meshNodeId?: string;
|
|
44
44
|
meshCoordinatorDaemonId?: string;
|
|
45
|
+
// The ORIGINATING coordinator SESSION (not just its daemon). Carried so a worker's
|
|
46
|
+
// completion event can be routed back to the exact coordinator session that
|
|
47
|
+
// dispatched the work, even when several coordinator sessions share one daemon
|
|
48
|
+
// (the multi-coordinator misroute). Optional + absent on legacy dispatches, in
|
|
49
|
+
// which case routing falls back to the daemon-level anchor (current behaviour).
|
|
50
|
+
meshCoordinatorSessionId?: string;
|
|
45
51
|
launchedByCoordinator?: boolean;
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -63,6 +69,7 @@ export function buildMeshWorkerRelayStamp(
|
|
|
63
69
|
meshId?: unknown;
|
|
64
70
|
nodeId?: unknown;
|
|
65
71
|
coordinatorDaemonId?: unknown;
|
|
72
|
+
coordinatorSessionId?: unknown;
|
|
66
73
|
} | undefined,
|
|
67
74
|
): MeshWorkerRelayStamp | undefined {
|
|
68
75
|
if (!meshContext) return undefined;
|
|
@@ -80,9 +87,17 @@ export function buildMeshWorkerRelayStamp(
|
|
|
80
87
|
stamp.meshCoordinatorDaemonId = coordinatorDaemonId;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
// Session-level anchor (multi-coordinator routing): stamp the originating
|
|
91
|
+
// coordinator session id so the completion event can target the exact session.
|
|
92
|
+
// Carried over P2P to remote workers so a remote worker's echo returns it.
|
|
93
|
+
const coordinatorSessionId = readNonEmptyString(meshContext.coordinatorSessionId);
|
|
94
|
+
if (coordinatorSessionId && !readNonEmptyString(settings.meshCoordinatorSessionId)) {
|
|
95
|
+
stamp.meshCoordinatorSessionId = coordinatorSessionId;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
// A dispatch from a coordinator is itself proof of delegation; stamp it when the
|
|
84
99
|
// session is being given any mesh routing context but has no marker yet.
|
|
85
|
-
if ((meshId || nodeId || coordinatorDaemonId) && settings.launchedByCoordinator !== true) {
|
|
100
|
+
if ((meshId || nodeId || coordinatorDaemonId || coordinatorSessionId) && settings.launchedByCoordinator !== true) {
|
|
86
101
|
stamp.launchedByCoordinator = true;
|
|
87
102
|
}
|
|
88
103
|
|
|
@@ -109,6 +124,30 @@ export function readWorkerResultMetadata(event: Record<string, unknown>): Record
|
|
|
109
124
|
|
|
110
125
|
const MESH_SURFACED_PREVIEW_MAX_CHARS = 512;
|
|
111
126
|
|
|
127
|
+
// Cap for the worker final summary surfaced INLINE into the coordinator's chat
|
|
128
|
+
// (buildMeshSystemMessage). Larger than the mirror preview cap because this is the
|
|
129
|
+
// coordinator-facing payload that replaces a "go call mesh_read_chat" instruction —
|
|
130
|
+
// it should carry enough of the worker's result to act on without a second round-trip,
|
|
131
|
+
// while still bounding what is written into the coordinator PTY.
|
|
132
|
+
const MESH_COMPLETION_SURFACE_MAX_CHARS = 4000;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The worker's final assistant text carried on a completion event — read from
|
|
136
|
+
* `finalSummary` (and the `workerResult.summary` / `result.summary` fallbacks some
|
|
137
|
+
* paths use). Returns '' when the event carries no assistant text (lifecycle events
|
|
138
|
+
* without a summary). Shared by the coordinator chat surface (buildMeshSystemMessage)
|
|
139
|
+
* and the held-event ledger audit record so both read the summary the same way.
|
|
140
|
+
*/
|
|
141
|
+
export function readMeshCompletionSummary(metadataEvent: Record<string, unknown>): string {
|
|
142
|
+
const workerResult = readWorkerResultMetadata(metadataEvent);
|
|
143
|
+
const resultRecord = readRecord(metadataEvent.result);
|
|
144
|
+
return readNonEmptyString(metadataEvent.finalSummary)
|
|
145
|
+
|| readNonEmptyString(workerResult?.summary)
|
|
146
|
+
|| readNonEmptyString(workerResult?.finalSummary)
|
|
147
|
+
|| readNonEmptyString(resultRecord?.summary)
|
|
148
|
+
|| readNonEmptyString(resultRecord?.finalSummary);
|
|
149
|
+
}
|
|
150
|
+
|
|
112
151
|
/**
|
|
113
152
|
* A coordinator that surfaces a REMOTE worker's mesh session has no local instance for
|
|
114
153
|
* it, so the status snapshot's getLastDisplayMessage has nothing to read and the only
|
|
@@ -126,13 +165,7 @@ const MESH_SURFACED_PREVIEW_MAX_CHARS = 512;
|
|
|
126
165
|
export function resolveMeshSurfacedSessionPreview(
|
|
127
166
|
metadataEvent: Record<string, unknown>,
|
|
128
167
|
): { preview: string; role: 'assistant'; receivedAt: number } | undefined {
|
|
129
|
-
const
|
|
130
|
-
const resultRecord = readRecord(metadataEvent.result);
|
|
131
|
-
const summaryText = readNonEmptyString(metadataEvent.finalSummary)
|
|
132
|
-
|| readNonEmptyString(workerResult?.summary)
|
|
133
|
-
|| readNonEmptyString(workerResult?.finalSummary)
|
|
134
|
-
|| readNonEmptyString(resultRecord?.summary)
|
|
135
|
-
|| readNonEmptyString(resultRecord?.finalSummary);
|
|
168
|
+
const summaryText = readMeshCompletionSummary(metadataEvent);
|
|
136
169
|
if (!summaryText) return undefined;
|
|
137
170
|
const truncationSuffix = '...[truncated]';
|
|
138
171
|
const preview = summaryText.length > MESH_SURFACED_PREVIEW_MAX_CHARS
|
|
@@ -186,7 +219,26 @@ export function buildMeshSystemMessage(args: {
|
|
|
186
219
|
if (args.metadataEvent.source === 'no_progress_reconciliation') {
|
|
187
220
|
return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The no-progress monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
|
|
188
221
|
}
|
|
189
|
-
const
|
|
222
|
+
const reviewRecommended = args.metadataEvent.reviewRecommended === true;
|
|
223
|
+
// Auto-surface the worker's final summary directly into the coordinator chat so it
|
|
224
|
+
// does not have to call mesh_read_chat just to see the result. The summary IS the
|
|
225
|
+
// worker's final assistant message; embedding it here replaces the previous
|
|
226
|
+
// "go call mesh_read_chat" instruction with the answer itself. This rides the
|
|
227
|
+
// existing (non-modal) coordinator delivery channel — it does not write into a
|
|
228
|
+
// parked harness modal. Falls back to the read_chat instruction only when the event
|
|
229
|
+
// genuinely carries no summary (so behaviour is unchanged for summary-less events).
|
|
230
|
+
const completionSummary = readMeshCompletionSummary(args.metadataEvent);
|
|
231
|
+
if (completionSummary) {
|
|
232
|
+
const truncationSuffix = '\n…[truncated — call mesh_read_chat once for the full transcript]';
|
|
233
|
+
const surfaced = completionSummary.length > MESH_COMPLETION_SURFACE_MAX_CHARS
|
|
234
|
+
? `${completionSummary.slice(0, MESH_COMPLETION_SURFACE_MAX_CHARS - truncationSuffix.length)}${truncationSuffix}`
|
|
235
|
+
: completionSummary;
|
|
236
|
+
const verifyNote = reviewRecommended
|
|
237
|
+
? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done.'
|
|
238
|
+
: '';
|
|
239
|
+
return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. Its final summary is included below — read it directly and only call mesh_read_chat if you need the full transcript.${verifyNote}\n\n--- ${args.nodeLabel} final summary ---\n${surfaced}`;
|
|
240
|
+
}
|
|
241
|
+
const reviewNote = reviewRecommended
|
|
190
242
|
? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done. Use mesh_read_chat once if needed, but do not poll repeatedly.'
|
|
191
243
|
: ' Use mesh_read_chat once to review its final progress, but do not poll repeatedly.';
|
|
192
244
|
return `[System] ${args.nodeLabel} has completed its task and is now idle${metadata}. This completion came from the agent status event path;${reviewNote}`;
|