@adhdev/daemon-core 0.9.82-rc.400 → 0.9.82-rc.402

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.
@@ -566,11 +566,23 @@ export class MeshRuntimeStore {
566
566
 
567
567
  /** A node may only execute one write task at a time (worktree isolation). */
568
568
  private hasActiveNodeAssignment(meshId: string, nodeId: string): boolean {
569
+ // The serialization gate (claimNextQueueTask's `!nodeBusy`) must see a node as
570
+ // busy when ANY active row's assigned_node_id matches in ANY equivalent
571
+ // daemon-id form (config-form `daemon_mach_X` vs stamp-form `mach_X`, or the
572
+ // standalone form). A raw `assigned_node_id = ?` on a single form silently
573
+ // misses a form-variant assigned row, making an already-assigned node look idle
574
+ // and letting a second write task claim it — duplicate claim / base leak. Mirror
575
+ // the node-pinned SELECT below (the `target_node_id IN (...)` query): expand to
576
+ // every equivalent form and bind an IN (...) set so the busy gate and the
577
+ // candidate SELECT use the SAME matching rule.
578
+ const nodeIdForms = expandDaemonIdForms(nodeId);
579
+ if (nodeIdForms.length === 0) return false;
580
+ const placeholders = nodeIdForms.map(() => '?').join(', ');
569
581
  const row = this.db.prepare(`
570
582
  SELECT 1 FROM mesh_queue
571
- WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
583
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id IN (${placeholders})
572
584
  LIMIT 1
573
- `).get(meshId, nodeId);
585
+ `).get(meshId, ...nodeIdForms);
574
586
  return row !== undefined;
575
587
  }
576
588
 
@@ -772,6 +784,12 @@ export class MeshRuntimeStore {
772
784
  // an empty session. Accept the candidate when the target resolves to the
773
785
  // same node under ANY equivalent form; keep targetSessionId an exact match.
774
786
  const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
787
+ // SESSION-ID IS SINGLE-FORM: unlike node/daemon ids (3 serialization
788
+ // forms requiring expandDaemonIdForms), a session id is a single
789
+ // canonical UUID minted once via crypto.randomUUID() in the provider
790
+ // instance (cli/acp/extension/ide) and carried verbatim across daemons
791
+ // (resolveEventSessionId applies no transformation). So an exact `!==`
792
+ // is correct here and needs no normalization helper.
775
793
  if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
776
794
  if (
777
795
  candidate.targetNodeId
@@ -7,6 +7,7 @@ import { getMesh } from '../config/mesh-config.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
9
  import type { MeshLedgerKind } from './mesh-ledger.js';
10
+ import { createSessionDelivery } from './mesh-delivery-policy.js';
10
11
 
11
12
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
12
13
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -836,6 +837,28 @@ export function recordDirectDispatchTask(
836
837
  updatedAt: now,
837
838
  };
838
839
  MeshRuntimeStore.getInstance().insertQueueEntry(entry);
840
+ // R2 / NOTIF-DROP: a mission-attributed DIRECT dispatch (mesh_send_task) has
841
+ // already been handed to the transport by the time we materialise this assigned
842
+ // row — unlike a queue claim, there is no later delivery-confirmation write for
843
+ // it. Without a confirmed delivery record keyed by this taskId, the assigned-
844
+ // stranded watchdog (recoverStrandedAssignedDispatches → taskHasConfirmedDelivery)
845
+ // sees the row as never-confirmed after ASSIGNED_STRANDED_DEADLINE_MS and reclaims
846
+ // a task the worker already COMPLETED, dropping its agent:generating_completed
847
+ // (live PROBE-B repro: "never confirmed delivered → pending"). Record a confirmed
848
+ // delivery here so taskHasConfirmedDelivery() is true and the watchdog leaves the
849
+ // row to PHASE 4 completion reconcile. This point is only reached after the direct
850
+ // dispatch's result.success, so 'delivered' is the accurate state.
851
+ try {
852
+ createSessionDelivery({
853
+ meshId,
854
+ ...(opts.assignedNodeId ? { nodeId: opts.assignedNodeId } : {}),
855
+ ...(opts.assignedSessionId ? { sessionId: opts.assignedSessionId } : {}),
856
+ taskId,
857
+ kind: 'task',
858
+ message,
859
+ status: 'delivered',
860
+ });
861
+ } catch { /* best-effort — the assigned row is already recorded */ }
839
862
  return entry;
840
863
  });
841
864
  }
@@ -62,6 +62,12 @@ type CompletedDebouncePending = {
62
62
  loggedBlockReason?: string;
63
63
  loggedTranscriptProbe?: boolean;
64
64
  transcriptProbeHistory?: ExternalTranscriptProbe[];
65
+ // ARCH-REFACTOR R1: the taskId of the turn that produced this (debounced) completion,
66
+ // captured SYNCHRONOUSLY at the generating→idle transition. The actual completion
67
+ // event is emitted later by the debounce flush, by which point a follow-up task may
68
+ // already have started its own turn and overwritten engine.currentTurnTaskId — so the
69
+ // id must be snapshotted here, not re-read at flush time.
70
+ taskId?: string;
65
71
  };
66
72
 
67
73
  function isIdleStatus(value: unknown): boolean {
@@ -1641,11 +1647,29 @@ export class CliProviderInstance implements ProviderInstance {
1641
1647
  || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1642
1648
  }
1643
1649
 
1650
+ /**
1651
+ * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
1652
+ * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
1653
+ * submitted and surviving until the next turn starts) over the last-write-wins
1654
+ * session scalar (settings.meshActiveTaskId). The scalar is retained only as a
1655
+ * backward-compat alias for the "current/last assignment" and is the source of the
1656
+ * NOTIF-MISDELIVER / TASK-MSG-MISROUTE race: a second task attaching before this
1657
+ * turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
1658
+ */
1659
+ private completingTurnTaskId(): string | undefined {
1660
+ const turnTaskId = this.adapter?.currentTurnTaskId;
1661
+ if (typeof turnTaskId === 'string' && turnTaskId.trim()) return turnTaskId;
1662
+ const scalar = this.settings.meshActiveTaskId;
1663
+ return typeof scalar === 'string' && scalar.trim() ? scalar : undefined;
1664
+ }
1665
+
1644
1666
  // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1645
1667
  // the primary grep anchor; instanceId is the session fallback.
1646
1668
  private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1647
1669
  return {
1648
- taskId: this.settings.meshActiveTaskId,
1670
+ // ARCH-REFACTOR R1: trace the per-turn taskId (falling back to the scalar) so
1671
+ // EvtTrace anchors on the same id the completion event actually carries.
1672
+ taskId: this.completingTurnTaskId(),
1649
1673
  sessionId: this.instanceId,
1650
1674
  nodeId: this.settings.meshNodeId,
1651
1675
  meshId: this.settings.meshNodeFor,
@@ -1738,6 +1762,8 @@ export class CliProviderInstance implements ProviderInstance {
1738
1762
  chatTitle: pending.chatTitle,
1739
1763
  duration: pending.duration,
1740
1764
  timestamp: pending.timestamp,
1765
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
1766
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
1741
1767
  // When finalization is forced past the timeout on a `parsed_status:` block
1742
1768
  // (the parser never confirmed a final assistant turn) we previously rode an
1743
1769
  // empty `finalSummary` unconditionally. That empty value propagates to the
@@ -1767,6 +1793,8 @@ export class CliProviderInstance implements ProviderInstance {
1767
1793
  chatTitle: pending.chatTitle,
1768
1794
  duration: pending.duration,
1769
1795
  timestamp: pending.timestamp,
1796
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
1797
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
1770
1798
  finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1771
1799
  });
1772
1800
  this.completedDebouncePending = null;
@@ -2227,6 +2255,10 @@ export class CliProviderInstance implements ProviderInstance {
2227
2255
  timestamp: now,
2228
2256
  firstObservedAt: now,
2229
2257
  previousStatus: this.lastStatus,
2258
+ // ARCH-REFACTOR R1: snapshot the completing turn's taskId NOW (sync),
2259
+ // before any follow-up task's flush can start a new turn and move
2260
+ // engine.currentTurnTaskId.
2261
+ ...(this.completingTurnTaskId() ? { taskId: this.completingTurnTaskId() } : {}),
2230
2262
  };
2231
2263
  const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2232
2264
  // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
@@ -2374,17 +2406,29 @@ export class CliProviderInstance implements ProviderInstance {
2374
2406
  // a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
2375
2407
  // to key on task_id (CANON-B), but the producer never carried it — so every
2376
2408
  // forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
2377
- // to a session_id match, which can flip a sibling dispatch row. The session
2378
- // already knows its own taskId via attachMeshAssignment (settings.meshActiveTaskId);
2379
- // surface it here so updateDirectDispatchStatus hits the exact PK row and the
2380
- // session_id fallback is never exercised. Non-mesh sessions get no taskId
2381
- // (regression guard) — isMeshWorkerSession() gates the injection.
2382
- if (this.isMeshWorkerSession() && this.settings.meshActiveTaskId) {
2409
+ // to a session_id match, which can flip a sibling dispatch row. Surface it here so
2410
+ // updateDirectDispatchStatus hits the exact PK row and the session_id fallback is
2411
+ // never exercised. Non-mesh sessions get no taskId (regression guard)
2412
+ // isMeshWorkerSession() gates the injection.
2413
+ //
2414
+ // ARCH-REFACTOR R1 (per-turn identity): resolution order is
2415
+ // (1) an explicit taskId already on the event — the debounce-flush completion
2416
+ // path stamps the taskId captured at the generating→idle transition (the
2417
+ // turn that actually produced this completion);
2418
+ // (2) the per-turn binding (engine.currentTurnTaskId) for synchronously-emitted
2419
+ // events whose turn is still the current one;
2420
+ // (3) the legacy session scalar (settings.meshActiveTaskId) as a last-resort
2421
+ // backward-compat alias.
2422
+ // The scalar is last because it is last-write-wins: a second task attaching while
2423
+ // this turn was still running overwrites it, which is the exact NOTIF-MISDELIVER /
2424
+ // TASK-MSG-MISROUTE race this refactor removes.
2425
+ if (this.isMeshWorkerSession()) {
2383
2426
  const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
2384
2427
  ? enrichedEvent.taskId
2385
2428
  : undefined;
2386
2429
  if (!existingTaskId) {
2387
- enrichedEvent.taskId = this.settings.meshActiveTaskId;
2430
+ const resolved = this.completingTurnTaskId();
2431
+ if (resolved) enrichedEvent.taskId = resolved;
2388
2432
  }
2389
2433
  }
2390
2434
  if (this.context?.emitProviderEvent) {