@adhdev/daemon-core 0.9.82-rc.421 → 0.9.82-rc.422

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.
@@ -84,15 +84,30 @@ export declare class ProviderInstanceManager {
84
84
  updateInstanceSettings(providerType: string, settings: Record<string, any>): number;
85
85
  /** Stamp a mesh assignment on a single instance (used by mesh_send_task
86
86
  * --direct so the worker's completion event has a coordinator routing
87
- * marker in state.settings). Returns true if the instance existed and
88
- * the stamp was applied. */
87
+ * marker in state.settings). Returns `{ stamped: true }` when the stamp was
88
+ * applied, or `{ stamped: false, reason }` when it was refused — the instance
89
+ * was missing / has no attach method, or the DOUBLE-DISPATCH idempotence guard
90
+ * fired (the same task is already running on another live session here). */
89
91
  attachMeshAssignmentToInstance(instanceId: string, assignment: {
90
92
  meshId: string;
91
93
  nodeId?: string;
92
94
  taskId?: string;
93
95
  coordinatorDaemonId?: string;
94
96
  coordinatorSessionId?: string;
95
- }): boolean;
97
+ }): {
98
+ stamped: boolean;
99
+ reason?: string;
100
+ };
101
+ /**
102
+ * DOUBLE-DISPATCH support: the id of another LIVE, actively-working instance that already
103
+ * holds (meshId, taskId), or null. "Live working" = stamped with this exact mesh+task AND
104
+ * currently mid-turn / booting toward it (generating / waiting on approval-or-choice /
105
+ * starting) — NOT idle, stopped, or errored. A stale/dead/idle holder is deliberately
106
+ * ignored so a legitimate re-dispatch (e.g. after a dispatch failure) is never blocked.
107
+ * The instance being stamped (excludeInstanceId) is skipped so re-stamping the same
108
+ * session stays idempotent. O(n) over instances — the count is small.
109
+ */
110
+ private findLiveWorkingTaskHolder;
96
111
  /** Clear a mesh assignment after the dispatched task reaches a terminal
97
112
  * state (generating_completed / stopped / failed). */
98
113
  detachMeshAssignmentFromInstance(instanceId: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.421",
3
+ "version": "0.9.82-rc.422",
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.421",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.422",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1535,14 +1535,22 @@ export class DaemonCliManager {
1535
1535
  const meshContext = (args as any)?.meshContext;
1536
1536
  if (meshContext && typeof meshContext === 'object' && typeof meshContext.meshId === 'string' && meshContext.meshId) {
1537
1537
  const targetInstanceId = key;
1538
+ let stampResult: { stamped: boolean; reason?: string } | undefined;
1538
1539
  try {
1539
- this.deps.getInstanceManager()?.attachMeshAssignmentToInstance(targetInstanceId, {
1540
+ stampResult = this.deps.getInstanceManager()?.attachMeshAssignmentToInstance(targetInstanceId, {
1540
1541
  meshId: meshContext.meshId,
1541
1542
  ...(typeof meshContext.nodeId === 'string' && meshContext.nodeId ? { nodeId: meshContext.nodeId } : {}),
1542
1543
  ...(typeof meshContext.taskId === 'string' && meshContext.taskId ? { taskId: meshContext.taskId } : {}),
1543
1544
  ...(typeof meshContext.coordinatorDaemonId === 'string' && meshContext.coordinatorDaemonId ? { coordinatorDaemonId: meshContext.coordinatorDaemonId } : {}),
1544
1545
  });
1545
- } catch { /* best-effort */ }
1546
+ } catch { /* best-effort — stamping is a routing aid, not a hard requirement */ }
1547
+ // DOUBLE-DISPATCH stamp guard: the instance manager refused this stamp because
1548
+ // the SAME task is already running on another live session on this daemon.
1549
+ // Sending the prompt anyway would double-execute the task — fail closed so the
1550
+ // coordinator does not duplicate the work onto a second session.
1551
+ if (stampResult && stampResult.stamped === false && stampResult.reason === 'task_already_stamped_on_live_instance') {
1552
+ throw new Error(`Refusing duplicate mesh dispatch: task ${meshContext.taskId} is already being worked by a live session on this daemon`);
1553
+ }
1546
1554
  }
1547
1555
  const input = normalizeInputEnvelope(args?.input ? { input: args.input } : args);
1548
1556
  const provider = this.providerLoader.resolve(agentType) || this.providerLoader.getMeta(agentType);
@@ -1098,6 +1098,47 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
1098
1098
  }).length;
1099
1099
  }
1100
1100
 
1101
+ /**
1102
+ * DOUBLE-DISPATCH auto-launch gate: does this node already have a LIVE mesh session that
1103
+ * is NOT holding an assigned queue task — i.e. one that is idle, booting toward its first
1104
+ * claim, or in a momentary non-idle flip? Such a session WILL claim a still-pending task
1105
+ * on its own via the idle→claim / agent:ready drain, so spawning a NEW session here only
1106
+ * races it and yields a duplicate worker that double-stamps the same taskId (the
1107
+ * enqueue → drain-miss → auto-launch RCA: the drain skipped a momentarily-non-idle idle
1108
+ * session as a candidate, and the write-only nodeHasActiveAssignment gate — which only
1109
+ * inspects status='assigned' rows — could not see the about-to-claim session either).
1110
+ *
1111
+ * A session that already HOLDS an assigned queue task is genuine concurrent work, not a
1112
+ * free claimer, and is excluded — so a read-only auto-launch onto a busy-but-no-idle node
1113
+ * is still allowed. A node with NO live mesh session at all (dead, or never launched) does
1114
+ * not match, preserving the legitimate first-session spawn.
1115
+ */
1116
+ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string): boolean {
1117
+ // Session ids currently holding an assigned queue task on this node — those are busy,
1118
+ // not pending claimers, so they must NOT suppress a (read-only) launch.
1119
+ const busySessionIds = new Set(
1120
+ getQueue(meshId, { status: ['assigned'] as any })
1121
+ .filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId))
1122
+ .map(task => readNonEmptyString(task.assignedSessionId))
1123
+ .filter(Boolean),
1124
+ );
1125
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
1126
+ const state = inst.getState();
1127
+ const settings = state.settings as Record<string, unknown> || {};
1128
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1129
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1130
+ // Canonical-form match (see nodeHasActiveMeshWork / liveSessionCountForNode): a
1131
+ // daemon-id form skew must not make a present session look absent and reopen the
1132
+ // duplicate-launch hole.
1133
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
1134
+ const status = readNonEmptyString(state.status).toLowerCase();
1135
+ if (isTerminalSessionStatus(status)) return false; // dead → no claimer here, allow launch
1136
+ const sessionId = readNonEmptyString(state.instanceId);
1137
+ if (sessionId && busySessionIds.has(sessionId)) return false; // busy with its own assigned task
1138
+ return true; // live + unassigned → will claim the pending task itself
1139
+ });
1140
+ }
1141
+
1101
1142
  function recordAutoLaunchEvent(meshId: string, args: {
1102
1143
  phase: 'skipped' | 'started' | 'failed' | 'completed';
1103
1144
  taskId: string;
@@ -1417,6 +1458,20 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1417
1458
  autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1418
1459
  continue;
1419
1460
  }
1461
+ // DOUBLE-DISPATCH auto-launch gate (see nodeHasLiveSessionPendingClaim): when this
1462
+ // node already has a live session on its way to claim (idle / booting / momentary
1463
+ // non-idle flip), do NOT spawn a second one — that session pulls the pending task
1464
+ // via the normal idle→claim / agent:ready drain. Launching here races it and yields
1465
+ // a duplicate worker that double-stamps the same taskId. Applies to read-only tasks
1466
+ // too: an idle session can claim either kind, while a genuinely BUSY session (holding
1467
+ // its own assigned task) is excluded by the helper, so a read-only launch onto a
1468
+ // busy-but-no-idle node is still allowed. Skip with a transient (non-actionable)
1469
+ // reason so the coordinator is not paged; the 4s reconcile retries, and once the
1470
+ // existing session goes terminal this gate clears and a legitimate launch proceeds.
1471
+ if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId)) {
1472
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_live_session_pending_claim', nodeId });
1473
+ continue;
1474
+ }
1420
1475
  // Write tasks keep the one-active-per-node invariant (worktree isolation);
1421
1476
  // read-only diagnoses may auto-launch onto a node that already has an active
1422
1477
  // assignment. Classified by the shared isTaskReadonly predicate.
@@ -308,23 +308,64 @@ export class ProviderInstanceManager {
308
308
 
309
309
  /** Stamp a mesh assignment on a single instance (used by mesh_send_task
310
310
  * --direct so the worker's completion event has a coordinator routing
311
- * marker in state.settings). Returns true if the instance existed and
312
- * the stamp was applied. */
313
- attachMeshAssignmentToInstance(instanceId: string, assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): boolean {
311
+ * marker in state.settings). Returns `{ stamped: true }` when the stamp was
312
+ * applied, or `{ stamped: false, reason }` when it was refused — the instance
313
+ * was missing / has no attach method, or the DOUBLE-DISPATCH idempotence guard
314
+ * fired (the same task is already running on another live session here). */
315
+ attachMeshAssignmentToInstance(instanceId: string, assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): { stamped: boolean; reason?: string } {
314
316
  const inst = this.instances.get(instanceId);
315
317
  if (!inst || typeof inst.attachMeshAssignment !== 'function') {
316
- try {
317
- const { LOG } = require('../logging/logger.js');
318
- LOG.warn?.('MeshDispatch', `attachMeshAssignment skipped: instance ${instanceId} ${inst ? 'has no attach method' : 'not found'}`);
319
- } catch { /* noop */ }
320
- return false;
318
+ LOG.warn('MeshDispatch', `attachMeshAssignment skipped: instance ${instanceId} ${inst ? 'has no attach method' : 'not found'}`);
319
+ return { stamped: false, reason: inst ? 'instance_has_no_attach_method' : 'instance_not_found' };
320
+ }
321
+ // DOUBLE-DISPATCH stamp idempotence guard (defense in depth): refuse to stamp this
322
+ // (meshId, taskId) onto a SECOND instance when a DIFFERENT, still-live and actively
323
+ // working instance already holds the exact same task. Two sessions carrying one taskId
324
+ // double-execute the work (the auto-launch race RCA: a delayed claim by the original
325
+ // session plus the new session's post-boot claim sequentially stamp the same task —
326
+ // the atomic claim only blocks SIMULTANEOUS claims). A stale/dead/idle prior holder is
327
+ // NOT a conflict — a legitimate re-dispatch after a dispatch failure must still stamp.
328
+ if (assignment.taskId) {
329
+ const conflict = this.findLiveWorkingTaskHolder(assignment.meshId, assignment.taskId, instanceId);
330
+ if (conflict) {
331
+ LOG.warn('MeshDispatch', `attachMeshAssignment refused: task ${assignment.taskId} (mesh ${assignment.meshId}) is already being worked by live session ${conflict} — skipping duplicate stamp on ${instanceId}`);
332
+ return { stamped: false, reason: 'task_already_stamped_on_live_instance' };
333
+ }
321
334
  }
322
335
  inst.attachMeshAssignment(assignment);
323
- try {
324
- const { LOG } = require('../logging/logger.js');
325
- LOG.info?.('MeshDispatch', `stamped mesh assignment on ${instanceId}: mesh=${assignment.meshId} node=${assignment.nodeId || ''} task=${assignment.taskId || ''} coordinator=${assignment.coordinatorDaemonId || ''}`);
326
- } catch { /* noop */ }
327
- return true;
336
+ LOG.info('MeshDispatch', `stamped mesh assignment on ${instanceId}: mesh=${assignment.meshId} node=${assignment.nodeId || ''} task=${assignment.taskId || ''} coordinator=${assignment.coordinatorDaemonId || ''}`);
337
+ return { stamped: true };
338
+ }
339
+
340
+ /**
341
+ * DOUBLE-DISPATCH support: the id of another LIVE, actively-working instance that already
342
+ * holds (meshId, taskId), or null. "Live working" = stamped with this exact mesh+task AND
343
+ * currently mid-turn / booting toward it (generating / waiting on approval-or-choice /
344
+ * starting) — NOT idle, stopped, or errored. A stale/dead/idle holder is deliberately
345
+ * ignored so a legitimate re-dispatch (e.g. after a dispatch failure) is never blocked.
346
+ * The instance being stamped (excludeInstanceId) is skipped so re-stamping the same
347
+ * session stays idempotent. O(n) over instances — the count is small.
348
+ */
349
+ private findLiveWorkingTaskHolder(meshId: string, taskId: string, excludeInstanceId: string): string | null {
350
+ // Mid-turn / booting statuses (top-level or activeChat). Anything else — idle, stopped,
351
+ // error — is not a live worker actively holding the task.
352
+ const working = new Set(['generating', 'waiting_approval', 'waiting_choice', 'starting', 'streaming', 'working', 'no_progress', 'long_generating']);
353
+ for (const [id, inst] of this.instances) {
354
+ if (id === excludeInstanceId) continue;
355
+ let state: ProviderState;
356
+ try {
357
+ state = inst.getState();
358
+ } catch {
359
+ continue;
360
+ }
361
+ const settings = (state.settings as Record<string, unknown>) || {};
362
+ if (settings.meshNodeFor !== meshId) continue;
363
+ if (settings.meshActiveTaskId !== taskId) continue;
364
+ const status = (typeof state.status === 'string' ? state.status : '').toLowerCase();
365
+ const chatStatus = (typeof state.activeChat?.status === 'string' ? state.activeChat.status : '').toLowerCase();
366
+ if (working.has(status) || working.has(chatStatus)) return id;
367
+ }
368
+ return null;
328
369
  }
329
370
 
330
371
  /** Clear a mesh assignment after the dispatched task reaches a terminal