@adhdev/daemon-core 0.9.82-rc.394 → 0.9.82-rc.396

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.
@@ -34,6 +34,15 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
34
34
  transcriptMessageAt?: string;
35
35
  completedAt?: string;
36
36
  targetCoordinatorDaemonId?: string;
37
+ /**
38
+ * NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
39
+ * task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
40
+ * dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
41
+ * STRICT routing matches the exact coordinator session — instead of relying on the
42
+ * daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
43
+ * ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
44
+ */
45
+ targetCoordinatorSessionId?: string;
37
46
  source?: string;
38
47
  }): {
39
48
  reconciled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.394",
3
+ "version": "0.9.82-rc.396",
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.394",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.396",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -3,7 +3,7 @@ import type { MeshLedgerKind } from './mesh-ledger.js';
3
3
  import { updateDirectDispatchStatus, cleanupTerminalDirectDispatches } from './mesh-work-queue.js';
4
4
  import { markSessionDeliveriesTerminal } from './mesh-delivery-policy.js';
5
5
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
6
- import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence } from './mesh-events-utils.js';
6
+ import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence, buildMeshSystemMessage } from './mesh-events-utils.js';
7
7
  import { meshNodeIdMatches, type MeshNodeIdentified } from '@adhdev/mesh-shared';
8
8
 
9
9
  // ---------------------------------------------------------------------------
@@ -188,6 +188,15 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
188
188
  transcriptMessageAt?: string;
189
189
  completedAt?: string;
190
190
  targetCoordinatorDaemonId?: string;
191
+ /**
192
+ * NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
193
+ * task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
194
+ * dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
195
+ * STRICT routing matches the exact coordinator session — instead of relying on the
196
+ * daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
197
+ * ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
198
+ */
199
+ targetCoordinatorSessionId?: string;
191
200
  source?: string;
192
201
  }): { reconciled: boolean; kind?: MeshLedgerKind; alreadyTerminal?: boolean; workerResult?: unknown; ledgerEntryId?: string; reason?: string } {
193
202
  const finalSummary = readNonEmptyString(args.finalSummary);
@@ -282,27 +291,44 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
282
291
  updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed', args.taskId);
283
292
  markSessionDeliveriesTerminal(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
284
293
  setImmediate(() => cleanupTerminalDirectDispatches());
294
+ // NOTIF-DROP-SYNTH-NO-MESSAGE: the queued synth completion MUST carry a coordinatorMessage,
295
+ // or injectPendingIntoCoordinator early-returns (`!pending.coordinatorMessage`) and the row is
296
+ // drained-without-inject → no [System] surface. The ~8s-later native completion then collides
297
+ // on the taskId-anchored fingerprint and is blocked at INSERT, so the notification is lost
298
+ // forever. Build the SAME [System] message the native path builds (buildMeshSystemMessage) so
299
+ // the synth is itself a complete, deliverable completion. Routing is made STRICT too: stamp the
300
+ // originating coordinator session so PHASE 2 strict-match delivers to the exact coordinator
301
+ // session that dispatched the task instead of falling back to a daemon-level broadcast.
302
+ const eventName = kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped';
303
+ const nodeLabel = nodeId ? `Node '${nodeId}'` : 'Remote agent';
304
+ const metadataEvent = {
305
+ targetSessionId: args.sessionId,
306
+ providerType: providerType || undefined,
307
+ providerSessionId: readNonEmptyString(args.providerSessionId),
308
+ finalSummary,
309
+ taskId: args.taskId,
310
+ workerResult,
311
+ completionDiagnostic: {
312
+ reason: 'direct_task_transcript_reconciliation',
313
+ terminalLedgerKind: kind,
314
+ terminalLedgerId: entry.id,
315
+ },
316
+ };
317
+ // Originating coordinator session: prefer the explicit arg; otherwise recover it from the
318
+ // task_dispatched ledger payload (the MCP dispatch path stamps `coordinatorSessionId` there).
319
+ // Absent on legacy rows → undefined → daemon-level routing (unchanged, no regression).
320
+ const targetCoordinatorSessionId = readNonEmptyString(args.targetCoordinatorSessionId)
321
+ || readNonEmptyString(dispatch?.payload?.coordinatorSessionId);
285
322
  queuePendingMeshCoordinatorEvent({
286
- event: kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped',
323
+ event: eventName,
287
324
  meshId: args.meshId,
288
- nodeLabel: nodeId ? `Node '${nodeId}'` : 'Remote agent',
325
+ nodeLabel,
289
326
  nodeId: nodeId || undefined,
290
- metadataEvent: {
291
- targetSessionId: args.sessionId,
292
- providerType: providerType || undefined,
293
- providerSessionId: readNonEmptyString(args.providerSessionId),
294
- finalSummary,
295
- taskId: args.taskId,
296
- workerResult,
297
- completionDiagnostic: {
298
- reason: 'direct_task_transcript_reconciliation',
299
- terminalLedgerKind: kind,
300
- terminalLedgerId: entry.id,
301
- },
302
- },
303
- coordinatorMessage: undefined,
327
+ metadataEvent,
328
+ coordinatorMessage: buildMeshSystemMessage({ event: eventName, nodeLabel, metadataEvent }),
304
329
  queuedAt: Date.now(),
305
330
  ...(readNonEmptyString(args.targetCoordinatorDaemonId) ? { targetCoordinatorDaemonId: readNonEmptyString(args.targetCoordinatorDaemonId) } : {}),
331
+ ...(targetCoordinatorSessionId ? { targetCoordinatorSessionId } : {}),
306
332
  });
307
333
 
308
334
  return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
@@ -269,6 +269,37 @@ export function tryAssignQueueTask(
269
269
  const mesh = getMeshWithCache(components, meshId);
270
270
  const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
271
271
 
272
+ // WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
273
+ // tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
274
+ // agent:ready drain, the triggerMeshQueue idle-session drain (local + remote), the
275
+ // auto-launch claim, and the PHASE 3 reconcile re-drain all call it. The agent:ready handler
276
+ // (mesh-event-forwarding) deferred its OWN claim while a worktree node's bootstrap was still
277
+ // 'running', but it ALSO called setRemoteIdleSession first — registering the session as a
278
+ // claim candidate. A concurrent triggerMeshQueue drain then pulled that candidate and claimed
279
+ // through tryAssignQueueTask within ~0.16s, BYPASSING the event-handler-local defer: the task
280
+ // dispatched into a half-built worktree (native addons not yet installed → child daemon dies
281
+ // → empty session, totalMessages=0). The transport ack returns ok:true, so neither the
282
+ // assigned-stranded watchdog nor the pending-only PHASE 3 reconcile ever re-fires it → the
283
+ // session is stranded empty forever.
284
+ //
285
+ // Lowering the gate HERE makes the defer a property of the claim itself, not of one caller:
286
+ // a worktree node whose bootstrap is still 'running' can never be claimed from any path. The
287
+ // task stays pending (we return false WITHOUT touching its status — no fail/cancel), so the
288
+ // bootstrap_complete refire (triggerMeshQueue re-fired on worktree_bootstrap_complete) re-runs
289
+ // this claim and passes once status is no longer 'running'. The registered remote-idle session
290
+ // persists for REMOTE_IDLE_SESSION_TTL_MS (5min > observed ~2m8s bootstrap), so the refire
291
+ // still finds a live candidate to re-claim. Identity uses meshNodeIdMatches (the same shared
292
+ // 3-form normalizer the defer guard uses), never a raw === — canon-identity regression guard.
293
+ // Conservative: any non-'running' status (idle/complete/failed/absent/unknown) does NOT gate,
294
+ // so a base node and a fully-bootstrapped worktree keep prior behavior exactly.
295
+ const gateNode = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId)) as
296
+ | { worktreeBootstrap?: { status?: string } }
297
+ | undefined;
298
+ if (gateNode?.worktreeBootstrap?.status === 'running') {
299
+ LOG.info('MeshQueue', `Gating queue claim for worktree node ${nodeId} (${sessionId}): worktree bootstrap still running — task left pending; claim re-fires once bootstrap reaches a terminal state (guards against dispatching into a half-built worktree → empty session)`);
300
+ return false;
301
+ }
302
+
272
303
  // WTCLAIM (fix-B extended to the enqueue→claim path): a base-targeted task must never be
273
304
  // claimed by — and dispatched into — a co-located worktree-clone session, nor vice versa.
274
305
  // The drain candidate's nodeId is derived from settings.meshNodeId || settings.nodeId
@@ -55,7 +55,7 @@ import {
55
55
  ackUnresolvedDelegateForward,
56
56
  expireStaleUnresolvedDelegateForwards,
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
- import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
58
+ import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
59
59
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
60
60
  import { expandDaemonIdForms, daemonIdsEquivalent } from '@adhdev/mesh-shared';
61
61
  import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
@@ -368,7 +368,30 @@ function injectPendingIntoCoordinator(
368
368
  coordinator: LiveCoordinator['instance'],
369
369
  pending: PendingMeshCoordinatorEvent,
370
370
  ): void {
371
- if (!coordinator || !pending.coordinatorMessage) return;
371
+ if (!coordinator) return;
372
+ // NOTIF-DROP-SYNTH-NO-MESSAGE (defence-in-depth): a queued event with no coordinatorMessage
373
+ // used to be dropped here (drain-without-inject) — the row had already been consumed
374
+ // (drained=1) by the caller's drain, so silently returning lost it forever. The primary fix
375
+ // makes the transcript-reconcile synth always carry a coordinatorMessage, but as a backstop,
376
+ // lazily synthesize the [System] text for any force-inject (terminal: completion / approval /
377
+ // stop / refine·bootstrap) event that still arrives message-less, so it surfaces instead of
378
+ // vanishing. A NON-force lifecycle event (agent:ready / generating_started) legitimately
379
+ // carries no message and must NOT be injected (it is queued only to re-drive the claim state
380
+ // machine on pull) — for it we still return without injecting.
381
+ let coordinatorMessage = pending.coordinatorMessage;
382
+ if (!coordinatorMessage) {
383
+ if (!shouldForceInjectMeshEvent(pending.event)) return;
384
+ const metadataEvent = pending.metadataEvent && typeof pending.metadataEvent === 'object'
385
+ ? pending.metadataEvent
386
+ : {};
387
+ coordinatorMessage = buildMeshSystemMessage({
388
+ event: pending.event,
389
+ nodeLabel: pending.nodeLabel,
390
+ metadataEvent,
391
+ });
392
+ if (!coordinatorMessage) return; // builder produced nothing — nothing to surface
393
+ LOG.warn('MeshReconcile', `Lazily synthesized missing coordinatorMessage for ${pending.event} (mesh ${pending.meshId}) at inject time — a queued terminal event arrived message-less`);
394
+ }
372
395
  const force = shouldForceInjectMeshEvent(pending.event);
373
396
  // EVTTRACE: event surfaced to the coordinator (injected into its live CLI session).
374
397
  // This is the terminal happy-path stage. Observation only.
@@ -380,7 +403,7 @@ function injectPendingIntoCoordinator(
380
403
  event: pending.event,
381
404
  }, force ? 'force-inject' : 'inject');
382
405
  coordinator.onEvent('send_message', {
383
- input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
406
+ input: { text: coordinatorMessage, textFallback: coordinatorMessage },
384
407
  ...(force ? { force: true } : {}),
385
408
  });
386
409
  }