@adhdev/daemon-core 0.9.82-rc.343 → 0.9.82-rc.345

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.
@@ -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.343",
3
+ "version": "0.9.82-rc.345",
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.343",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.345",
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 ───
@@ -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 cached inline-mesh nodes for the one whose active session matches the
3829
- * targetSessionId, and return its daemonId when that daemonId is a remote daemon (i.e.
3830
- * not this coordinator's own statusInstanceId). Returns undefined for a locally-hosted
3831
- * session (no forward — execute locally as before) or when ownership can't be resolved.
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.getCachedInlineMeshNodes()) {
3838
- const nodeSessions = readCachedInlineMeshActiveSessions(node);
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
- if (!nodeDaemonId) return undefined;
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 workerResult = readWorkerResultMetadata(metadataEvent);
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 reviewNote = args.metadataEvent.reviewRecommended === true
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}`;
@@ -42,6 +42,7 @@ export type MeshLedgerKind =
42
42
  | 'direct_fast_forward'
43
43
  | 'delivery_unroutable'
44
44
  | 'direct_dispatch_pruned'
45
+ | 'event_held'
45
46
  ;
46
47
 
47
48
  export interface MeshLedgerEntry {