@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.
@@ -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.342",
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.342",
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 ───
@@ -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,
@@ -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, 'failed');
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.