@adhdev/daemon-core 0.9.82-rc.411 → 0.9.82-rc.413

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,14 +13,29 @@ import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-deliv
13
13
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
14
  import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
- import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, distributionToStrategy } from '../repo-mesh-types.js';
16
+ import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { loadMeshJsonConfig, type MeshJsonSchedulingConfig } from '../config/mesh-json-config.js';
19
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
20
19
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
21
20
  import { readNonEmptyString } from './mesh-events-utils.js';
22
21
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
23
22
  import { isWorktreeBootstrapStaleRunning } from './worktree-bootstrap-config.js';
23
+ import { beginTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
24
+
25
+ /**
26
+ * CANON: the single canonical coordinator-daemon id this daemon stamps onto every
27
+ * worker dispatch (meshContext.coordinatorDaemonId / sourceCoordinatorDaemonId / the
28
+ * co-located meshCoordinatorDaemonId anchor). loadConfig().machineId is the bare
29
+ * `mach_X` form; canonicalizing to `daemon_mach_X` unifies it with the MCP-side
30
+ * resolveCoordinatorDaemonId producer so the two dispatch paths can never stamp a
31
+ * worker's coordinator anchor in two different forms — the CANON-IDENTITY
32
+ * double-dispatch root cause. Consumers of the anchor already compare under
33
+ * daemonIdsEquivalent / expandDaemonIdForms, so the exact form is form-agnostic on
34
+ * the read side; this only removes the producer-side skew.
35
+ */
36
+ function localCoordinatorDaemonId(): string | undefined {
37
+ return canonicalDaemonId(readNonEmptyString(loadConfig().machineId));
38
+ }
24
39
 
25
40
  // ---------------------------------------------------------------------------
26
41
  // Idle auto fast-forward throttle state
@@ -245,6 +260,10 @@ function deliverTaskToSession(
245
260
  // ledger entry so the reconcile loop re-dispatches it. Identical for both transports.
246
261
  LOG.error('MeshQueue', `Failed to dispatch task via ${ctx.transport} to node ${ctx.nodeId}: ${e?.message}`);
247
262
  updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
263
+ // The dispatch failed — the task is no longer in-flight (it returns to pending
264
+ // for a clean re-dispatch). Clear the single-flight mark so a legitimate
265
+ // requeue/re-claim is not blocked as if a worker were still generating.
266
+ endTaskDispatchInFlight(ctx.meshId, ctx.task.id);
248
267
  updateTaskStatus(ctx.meshId, ctx.task.id, 'pending');
249
268
  try {
250
269
  appendLedgerEntry(ctx.meshId, {
@@ -415,10 +434,18 @@ export function tryAssignQueueTask(
415
434
 
416
435
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
417
436
 
437
+ // CANON-IDENTITY single-flight: mark the just-claimed task in-flight the moment it
438
+ // is handed to a transport. The atomic claim already prevents a concurrent claim,
439
+ // but this lets requeueTask distinguish a genuinely-generating task (refuse the
440
+ // operator requeue — it would open a second session) from a stale assigned row
441
+ // (still requeueable). Cleared when the task leaves `assigned` (terminal / dispatch
442
+ // failure / cancel / reclaim).
443
+ beginTaskDispatchInFlight(meshId, task.id);
444
+
418
445
  if (node?.daemonId && components.dispatchMeshCommand) {
419
446
  const isLocalNode = components.cliManager.adapters.has(sessionId);
420
447
  if (!isLocalNode) {
421
- const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
448
+ const localDaemonIdForDispatch = localCoordinatorDaemonId();
422
449
  // (3) Originating coordinator session that enqueued this task — route its
423
450
  // completion back to that exact session (multi-coordinator). Carried over P2P
424
451
  // to the remote worker, which echoes it on its completion event.
@@ -478,7 +505,7 @@ export function tryAssignQueueTask(
478
505
  // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
479
506
  // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
480
507
  // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
481
- const localDaemonId = readNonEmptyString(loadConfig().machineId);
508
+ const localDaemonId = localCoordinatorDaemonId();
482
509
  const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
483
510
  inst.updateSettings({
484
511
  meshNodeFor: meshId,
@@ -510,7 +537,7 @@ export function tryAssignQueueTask(
510
537
  meshId,
511
538
  nodeId,
512
539
  taskId: task.id,
513
- ...(readNonEmptyString(loadConfig().machineId) ? { coordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
540
+ ...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
514
541
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
515
542
  },
516
543
  }),
@@ -522,7 +549,7 @@ export function tryAssignQueueTask(
522
549
  task,
523
550
  transport: 'local',
524
551
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
525
- ...(readNonEmptyString(loadConfig().machineId) ? { sourceCoordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
552
+ ...(localCoordinatorDaemonId() ? { sourceCoordinatorDaemonId: localCoordinatorDaemonId() } : {}),
526
553
  },
527
554
  );
528
555
 
@@ -728,7 +755,12 @@ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nod
728
755
  const settings = state.settings as Record<string, unknown> || {};
729
756
  if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
730
757
  const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
731
- if (instNodeId !== nodeId) return false;
758
+ // Match under canonical machine-core form, NOT a raw `!==`: a session's stamped
759
+ // meshNodeId and the candidate nodeId can carry interchangeable daemon-id forms
760
+ // (bare `mach_X` vs `daemon_mach_X`). A raw mismatch makes a BUSY node look idle,
761
+ // so the active-work gate passes and a SECOND session is launched/claimed for a
762
+ // task already running here — the CANON-IDENTITY duplicate dispatch.
763
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
732
764
  const sessionId = readNonEmptyString(state.instanceId);
733
765
  if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
734
766
  return sessionStateLooksActive(state);
@@ -792,7 +824,10 @@ function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
792
824
  const daemonId = readNonEmptyString(node?.daemonId);
793
825
  if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
794
826
  if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
795
- const coordinatorDaemonId = readNonEmptyString(loadConfig().machineId);
827
+ // CANON: stamp the canonical `daemon_mach_` coordinator anchor onto the remote
828
+ // worker (meshCoordinatorDaemonId) so its completion forwards back under the same
829
+ // form every other dispatch path uses — no producer-side coordinator-id skew.
830
+ const coordinatorDaemonId = localCoordinatorDaemonId();
796
831
  if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
797
832
  return { mode: 'remote', daemonId, coordinatorDaemonId };
798
833
  }
@@ -815,7 +850,12 @@ export function activeReadonlyAssignedCount(meshId: string): number {
815
850
  }
816
851
 
817
852
  function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
818
- return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedNodeId === nodeId);
853
+ // Canonical-form match, NOT a raw `===`: an assigned row stamped in one daemon-id
854
+ // form (e.g. `daemon_mach_X`) must still register as this node's active work when
855
+ // the candidate nodeId arrives bare (`mach_X`). A raw mismatch makes the node look
856
+ // free, letting a second write task auto-launch onto an already-busy node and
857
+ // breaking the one-write-per-node (worktree isolation) invariant.
858
+ return getQueue(meshId, { status: ['assigned'] as any }).some(task => daemonIdsEquivalent(task.assignedNodeId, nodeId));
819
859
  }
820
860
 
821
861
  /** Active (status='assigned') task count for a node — the load metric for
@@ -825,46 +865,15 @@ function nodeActiveLoad(meshId: string, nodeId: string): number {
825
865
  }
826
866
 
827
867
  /**
828
- * Resolve the canonical repo root for reading a mesh's in-tree `.adhdev/mesh.json`
829
- * overlay. Prefers a base (non-worktree) node's repoRoot/workspace — the canonical
830
- * checkout that carries the repo file and falls back to any node so a worktree-only
831
- * mesh still resolves a root. Returns '' when no node declares a path.
832
- */
833
- function resolveMeshRepoRootForScheduling(mesh: any): string {
834
- const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
835
- const pickRoot = (n: any) => readNonEmptyString(n?.repoRoot) || readNonEmptyString(n?.workspace);
836
- const base = nodes.find((n: any) => n?.isLocalWorktree !== true && pickRoot(n));
837
- if (base) return pickRoot(base);
838
- const anyNode = nodes.find((n: any) => pickRoot(n));
839
- return anyNode ? pickRoot(anyNode) : '';
840
- }
841
-
842
- /**
843
- * The repo-local `.adhdev/mesh.json` `policy.scheduling` overlay for this mesh, when
844
- * present and valid. LOCAL-WINS: a value here overrides the stored mesh policy. Cached
845
- * by mtime in the loader, so calling this on every reconcile tick is cheap.
846
- */
847
- function resolveMeshSchedulingOverride(mesh: any): MeshJsonSchedulingConfig | undefined {
848
- const repoRoot = resolveMeshRepoRootForScheduling(mesh);
849
- if (!repoRoot) return undefined;
850
- try {
851
- return loadMeshJsonConfig(repoRoot).config?.scheduling;
852
- } catch {
853
- return undefined;
854
- }
855
- }
856
-
857
- /**
858
- * The mesh-wide scheduling strategy. Resolution order (LOCAL-WINS):
859
- * 1. `.adhdev/mesh.json` policy.scheduling.distribution (2-mode → strategy), then
860
- * 2. the stored mesh policy schedulingStrategy raw 4-union (escape hatch), then
861
- * 3. 'first_eligible' (strict no-change default).
862
- * Only governs the final tie-break; eligibility, capacity, and priority gates apply
863
- * identically to every strategy.
868
+ * The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
869
+ * policy. Resolution order:
870
+ * 1. the stored mesh policy schedulingStrategy raw 4-union, then
871
+ * 2. 'first_eligible' (strict no-change default).
872
+ * Policy is machine-local only — there is no repo-file (`.adhdev/mesh.json`)
873
+ * overlay. Only governs the final tie-break; eligibility, capacity, and priority
874
+ * gates apply identically to every strategy.
864
875
  */
865
876
  function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
866
- const override = resolveMeshSchedulingOverride(mesh);
867
- if (override?.distribution) return distributionToStrategy(override.distribution);
868
877
  return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
869
878
  }
870
879
 
@@ -957,7 +966,7 @@ function orderEligibleNodes(
957
966
  * transaction; this only avoids spawning a session that would fail the claim. */
958
967
  function activeProviderAssignedCount(meshId: string, nodeId: string, providerType: string): number {
959
968
  return getQueue(meshId, { status: ['assigned'] as any })
960
- .filter(task => task.assignedNodeId === nodeId && task.assignedProviderType === providerType).length;
969
+ .filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId) && task.assignedProviderType === providerType).length;
961
970
  }
962
971
 
963
972
  export function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
@@ -984,7 +993,10 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
984
993
  const settings = state.settings as Record<string, unknown> || {};
985
994
  if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
986
995
  const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
987
- if (instNodeId !== nodeId) return false;
996
+ // Canonical-form match (see nodeHasActiveMeshWork): a daemon-id form skew between
997
+ // the session's stamped nodeId and the candidate nodeId must not undercount this
998
+ // node's live sessions, which would defeat the maxConcurrentSessions cap.
999
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
988
1000
  const status = readNonEmptyString(state.status).toLowerCase();
989
1001
  return !isTerminalSessionStatus(status);
990
1002
  }).length;
@@ -1131,19 +1143,15 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1131
1143
  const pending = queue.filter(task => task.status === 'pending');
1132
1144
  if (!pending.length) return false;
1133
1145
 
1134
- // Write cap + read-only cap resolved through the shared helpers, with the
1135
- // repo-local `.adhdev/mesh.json` overlay winning over the stored policy
1136
- // (LOCAL-WINS). Both the cap value and the read-only multiplier route through
1137
- // the same resolvers the observability projection uses, so the enforced and
1138
- // exposed caps can never drift.
1139
- const schedulingOverride = resolveMeshSchedulingOverride(mesh);
1140
- const maxParallelTasks = resolveMaxParallelTasks(
1141
- schedulingOverride?.maxParallel ?? mesh?.policy?.maxParallelTasks,
1142
- );
1146
+ // Write cap + read-only cap resolved through the shared helpers from the
1147
+ // MACHINE-LOCAL stored mesh policy (no repo-file overlay). These are the same
1148
+ // resolvers the observability projection uses, so the enforced and exposed
1149
+ // caps can never drift.
1150
+ const maxParallelTasks = resolveMaxParallelTasks(mesh?.policy?.maxParallelTasks);
1143
1151
  // Read-only diagnoses carry no isolation/merge cost, so they are exempt from the
1144
1152
  // write-task parallel cap. To prevent runaway auto-launch they get their own,
1145
- // higher safety cap (readonlyMultiplier × the write cap, default 2×).
1146
- const maxReadonlyParallelTasks = resolveMaxReadonlyParallelTasks(maxParallelTasks, schedulingOverride?.readonlyMultiplier);
1153
+ // higher safety cap (default 2× the write cap).
1154
+ const maxReadonlyParallelTasks = resolveMaxReadonlyParallelTasks(maxParallelTasks);
1147
1155
  for (const task of pending) {
1148
1156
  const isReadonly = isTaskReadonly(task);
1149
1157
  if (isReadonly) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CANON-IDENTITY single-flight dispatch guard.
3
+ *
4
+ * A queue task is double-dispatched into two worker sessions when a SECOND dispatch
5
+ * path opens after the first has already claimed + dispatched the task. The two
6
+ * observed paths:
7
+ * - the idle-claim drain / auto-launch both funnel through tryAssignQueueTask,
8
+ * which is serialized by the atomic claim transaction (claimNextQueueTask) — so
9
+ * two concurrent claims can never both win; and
10
+ * - the operator requeue tool (mesh_queue_requeue → requeueTask), which flips an
11
+ * already-`assigned` row back to `pending` REGARDLESS of whether the worker
12
+ * holding it is still generating. A requeue issued while the worker is mid-turn
13
+ * re-opens the task for a SECOND session to claim — the live `ade8586d` race.
14
+ *
15
+ * The atomic claim already discriminates pending-vs-assigned, but it cannot tell a
16
+ * GENUINELY-in-flight assigned row (dispatched, worker generating) from a STALE
17
+ * assigned row (dead session, dispatch never confirmed) — and the requeue contract
18
+ * must still reopen the stale case. This module is that discriminator: a task id is
19
+ * registered here ONLY by the dispatch path at the moment it hands the claimed task
20
+ * to a transport, and cleared the moment the task leaves the `assigned` state
21
+ * (terminal completion/failure, dispatch-failure requeue, cancel, reclaim, or a
22
+ * forced requeue). requeueTask consults it and refuses (no-op) to reopen a task that
23
+ * is still in-flight unless the caller passes `force`.
24
+ *
25
+ * Dependency-free leaf (no imports) so both mesh-queue-assignment (begin) and
26
+ * mesh-work-queue (clear + the requeue guard) can import it without a cycle. The key
27
+ * is `${meshId}::${taskId}`; a task id is a single-form UUID, so no daemon-id
28
+ * normalization is needed on the key itself.
29
+ */
30
+
31
+ const inFlight = new Set<string>();
32
+
33
+ function key(meshId: string, taskId: string): string {
34
+ return `${meshId}::${taskId}`;
35
+ }
36
+
37
+ /**
38
+ * Mark a task as actively dispatched/generating (in-flight). Called by the dispatch
39
+ * path right after a successful claim, before/as the task is handed to a transport.
40
+ * Returns true when this call transitioned the task into the in-flight set, false
41
+ * when it was already in-flight (a redundant begin — the caller may treat that as a
42
+ * signal that a dispatch is already live for this task).
43
+ */
44
+ export function beginTaskDispatchInFlight(meshId: string, taskId: string): boolean {
45
+ if (!meshId || !taskId) return false;
46
+ const k = key(meshId, taskId);
47
+ if (inFlight.has(k)) return false;
48
+ inFlight.add(k);
49
+ return true;
50
+ }
51
+
52
+ /** True while a task is actively dispatched/generating (registered by the dispatch
53
+ * path and not yet cleared by a terminal/requeue/cancel transition). */
54
+ export function isTaskDispatchInFlight(meshId: string, taskId: string): boolean {
55
+ if (!meshId || !taskId) return false;
56
+ return inFlight.has(key(meshId, taskId));
57
+ }
58
+
59
+ /** Clear a task's in-flight mark. Called whenever the task leaves the `assigned`
60
+ * state (terminal completion/failure, dispatch-failure requeue, cancel, reclaim, or
61
+ * a forced requeue). Idempotent / safe to call when the task was never in-flight. */
62
+ export function endTaskDispatchInFlight(meshId: string, taskId: string): void {
63
+ if (!meshId || !taskId) return;
64
+ inFlight.delete(key(meshId, taskId));
65
+ }
66
+
67
+ /** Test-only: drop all in-flight marks so a fresh test starts from a clean guard. */
68
+ export function __resetTaskDispatchInFlightForTests(): void {
69
+ inFlight.clear();
70
+ }
@@ -8,6 +8,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
10
  import { createSessionDelivery } from './mesh-delivery-policy.js';
11
+ import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
11
12
 
12
13
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
13
14
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -1001,6 +1002,9 @@ export function updateTaskStatus(
1001
1002
  if (!entry) return null;
1002
1003
  entry.status = status;
1003
1004
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1005
+ // Any transition OFF `assigned` ends the single-flight dispatch window (terminal
1006
+ // completion/failure, or the dispatch-failure requeue to `pending`).
1007
+ if (status !== 'assigned') endTaskDispatchInFlight(meshId, taskId);
1004
1008
  if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, taskId);
1005
1009
  return entry;
1006
1010
  });
@@ -1038,6 +1042,7 @@ export function cancelTask(
1038
1042
  entry.cancelledAt = now;
1039
1043
  if (opts?.reason) entry.cancelReason = opts.reason;
1040
1044
  MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1045
+ endTaskDispatchInFlight(meshId, taskId);
1041
1046
  propagateDependencyFailure(meshId, taskId);
1042
1047
  return entry;
1043
1048
  });
@@ -1074,6 +1079,20 @@ export function requeueTask(
1074
1079
  return withQueueLock(meshId, () => {
1075
1080
  const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1076
1081
  if (!entry) return null;
1082
+ // CANON-IDENTITY single-flight: refuse (no-op) to reopen a task whose dispatch
1083
+ // is still in-flight — the worker is actively generating on it. Requeueing it
1084
+ // here would flip the row back to `pending` and let a SECOND session claim the
1085
+ // SAME task (the live `ade8586d` requeue-while-generating double-dispatch). A
1086
+ // STALE assigned row (dead session, dispatch never confirmed) is NOT in-flight
1087
+ // — its mark was cleared on the dispatch failure — so it still requeues as
1088
+ // before. An explicit operator override (`force`) bypasses this guard.
1089
+ if (!opts?.force && isTaskDispatchInFlight(meshId, taskId)) {
1090
+ LOG.warn('MeshQueue', `Refusing to requeue task ${taskId} on mesh ${meshId}: it is actively dispatched/generating (single-flight in-flight). Requeueing now would open a duplicate second dispatch into another session. Pass force to override.`);
1091
+ return entry;
1092
+ }
1093
+ // Proceeding to requeue (or force-override): the prior dispatch is being abandoned,
1094
+ // so end the single-flight window for this task id.
1095
+ endTaskDispatchInFlight(meshId, taskId);
1077
1096
  const currentCount = entry.requeueCount || 0;
1078
1097
  const maxRetries = opts?.maxRetries ?? entry.maxRetries ?? 1;
1079
1098
  if (!opts?.force && currentCount >= maxRetries) {
@@ -1154,6 +1173,9 @@ export function reclaimStrandedAssignedTask(
1154
1173
  delete entry.dispatchTimestamp;
1155
1174
  entry.strandedReclaimCount = reclaims;
1156
1175
  entry.updatedAt = now;
1176
+ // The stranded assignment is being torn down (→ pending or failed); end its
1177
+ // single-flight window so a re-claim/requeue is not blocked.
1178
+ endTaskDispatchInFlight(meshId, taskId);
1157
1179
  if (reclaims > MAX_STRANDED_RECLAIMS) {
1158
1180
  // Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
1159
1181
  entry.status = 'failed';
@@ -1212,6 +1234,9 @@ export function updateSessionTaskStatus(
1212
1234
  }
1213
1235
  entry.status = status;
1214
1236
  store.updateQueueEntry(entry);
1237
+ // The worker reported a terminal/non-assigned outcome — the dispatch is over;
1238
+ // release the single-flight mark so the task id can be re-dispatched later.
1239
+ if (status !== 'assigned') endTaskDispatchInFlight(meshId, entry.id);
1215
1240
  if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
1216
1241
  return entry;
1217
1242
  });