@adhdev/daemon-core 0.9.82-rc.412 → 0.9.82-rc.414

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.
@@ -0,0 +1,46 @@
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
+ * Mark a task as actively dispatched/generating (in-flight). Called by the dispatch
32
+ * path right after a successful claim, before/as the task is handed to a transport.
33
+ * Returns true when this call transitioned the task into the in-flight set, false
34
+ * when it was already in-flight (a redundant begin — the caller may treat that as a
35
+ * signal that a dispatch is already live for this task).
36
+ */
37
+ export declare function beginTaskDispatchInFlight(meshId: string, taskId: string): boolean;
38
+ /** True while a task is actively dispatched/generating (registered by the dispatch
39
+ * path and not yet cleared by a terminal/requeue/cancel transition). */
40
+ export declare function isTaskDispatchInFlight(meshId: string, taskId: string): boolean;
41
+ /** Clear a task's in-flight mark. Called whenever the task leaves the `assigned`
42
+ * state (terminal completion/failure, dispatch-failure requeue, cancel, reclaim, or
43
+ * a forced requeue). Idempotent / safe to call when the task was never in-flight. */
44
+ export declare function endTaskDispatchInFlight(meshId: string, taskId: string): void;
45
+ /** Test-only: drop all in-flight marks so a fresh test starts from a clean guard. */
46
+ export declare function __resetTaskDispatchInFlightForTests(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.412",
3
+ "version": "0.9.82-rc.414",
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.412",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.414",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -74,8 +74,22 @@ export const meshQueueHandlers: Record<string, MedFamilyHandler> = {
74
74
  targetSessionId: typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : undefined,
75
75
  clearTargetNode: args?.clearTargetNode === true,
76
76
  clearTargetSession: args?.clearTargetSession !== false,
77
+ // CANON-IDENTITY: an in-flight (actively-generating) task is refused by
78
+ // default to avoid a duplicate second dispatch; an explicit operator
79
+ // force overrides that guard (and the retry cap).
80
+ force: args?.force === true,
77
81
  });
78
82
  if (!task) return { success: false, error: `Queue task '${taskId}' not found` };
83
+ // The single-flight guard returns the row UNCHANGED (still 'assigned') when it
84
+ // refuses an in-flight requeue — surface that as a clear, non-success signal so
85
+ // the coordinator does not believe a second dispatch was opened.
86
+ if (task.status === 'assigned' && args?.force !== true) {
87
+ return {
88
+ success: false,
89
+ error: `Task '${taskId}' is actively dispatched/generating; requeue refused to avoid a duplicate second dispatch. Pass force:true to override, or cancel and re-enqueue.`,
90
+ task,
91
+ };
92
+ }
79
93
  return { success: true, task };
80
94
  } catch (e: any) {
81
95
  return { success: false, error: e.message };
package/src/index.ts CHANGED
@@ -199,7 +199,7 @@ export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './con
199
199
  // e.g. the mcp-server, which depends only on @adhdev/daemon-core — can
200
200
  // canonicalize daemon-id and node-id forms without taking a direct
201
201
  // @adhdev/mesh-shared dependency). ──
202
- export { expandDaemonIdForms, daemonIdsEquivalent, machineCoreFromDaemonId } from '@adhdev/mesh-shared';
202
+ export { expandDaemonIdForms, daemonIdsEquivalent, machineCoreFromDaemonId, canonicalDaemonId } from '@adhdev/mesh-shared';
203
203
  export { normalizeMeshNodeId, meshNodeIdMatches } from '@adhdev/mesh-shared';
204
204
 
205
205
  // ── Mesh Coordinator ──
@@ -15,11 +15,27 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
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 { 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';
19
19
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
20
  import { readNonEmptyString } from './mesh-events-utils.js';
21
21
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
22
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
+ }
23
39
 
24
40
  // ---------------------------------------------------------------------------
25
41
  // Idle auto fast-forward throttle state
@@ -244,6 +260,10 @@ function deliverTaskToSession(
244
260
  // ledger entry so the reconcile loop re-dispatches it. Identical for both transports.
245
261
  LOG.error('MeshQueue', `Failed to dispatch task via ${ctx.transport} to node ${ctx.nodeId}: ${e?.message}`);
246
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);
247
267
  updateTaskStatus(ctx.meshId, ctx.task.id, 'pending');
248
268
  try {
249
269
  appendLedgerEntry(ctx.meshId, {
@@ -414,10 +434,18 @@ export function tryAssignQueueTask(
414
434
 
415
435
  LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
416
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
+
417
445
  if (node?.daemonId && components.dispatchMeshCommand) {
418
446
  const isLocalNode = components.cliManager.adapters.has(sessionId);
419
447
  if (!isLocalNode) {
420
- const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
448
+ const localDaemonIdForDispatch = localCoordinatorDaemonId();
421
449
  // (3) Originating coordinator session that enqueued this task — route its
422
450
  // completion back to that exact session (multi-coordinator). Carried over P2P
423
451
  // to the remote worker, which echoes it on its completion event.
@@ -477,7 +505,7 @@ export function tryAssignQueueTask(
477
505
  // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
478
506
  // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
479
507
  // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
480
- const localDaemonId = readNonEmptyString(loadConfig().machineId);
508
+ const localDaemonId = localCoordinatorDaemonId();
481
509
  const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
482
510
  inst.updateSettings({
483
511
  meshNodeFor: meshId,
@@ -509,7 +537,7 @@ export function tryAssignQueueTask(
509
537
  meshId,
510
538
  nodeId,
511
539
  taskId: task.id,
512
- ...(readNonEmptyString(loadConfig().machineId) ? { coordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
540
+ ...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
513
541
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
514
542
  },
515
543
  }),
@@ -521,7 +549,7 @@ export function tryAssignQueueTask(
521
549
  task,
522
550
  transport: 'local',
523
551
  ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
524
- ...(readNonEmptyString(loadConfig().machineId) ? { sourceCoordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
552
+ ...(localCoordinatorDaemonId() ? { sourceCoordinatorDaemonId: localCoordinatorDaemonId() } : {}),
525
553
  },
526
554
  );
527
555
 
@@ -727,7 +755,12 @@ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nod
727
755
  const settings = state.settings as Record<string, unknown> || {};
728
756
  if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
729
757
  const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
730
- 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;
731
764
  const sessionId = readNonEmptyString(state.instanceId);
732
765
  if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
733
766
  return sessionStateLooksActive(state);
@@ -791,7 +824,10 @@ function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
791
824
  const daemonId = readNonEmptyString(node?.daemonId);
792
825
  if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
793
826
  if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
794
- 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();
795
831
  if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
796
832
  return { mode: 'remote', daemonId, coordinatorDaemonId };
797
833
  }
@@ -814,7 +850,12 @@ export function activeReadonlyAssignedCount(meshId: string): number {
814
850
  }
815
851
 
816
852
  function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
817
- 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));
818
859
  }
819
860
 
820
861
  /** Active (status='assigned') task count for a node — the load metric for
@@ -925,7 +966,7 @@ function orderEligibleNodes(
925
966
  * transaction; this only avoids spawning a session that would fail the claim. */
926
967
  function activeProviderAssignedCount(meshId: string, nodeId: string, providerType: string): number {
927
968
  return getQueue(meshId, { status: ['assigned'] as any })
928
- .filter(task => task.assignedNodeId === nodeId && task.assignedProviderType === providerType).length;
969
+ .filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId) && task.assignedProviderType === providerType).length;
929
970
  }
930
971
 
931
972
  export function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
@@ -952,7 +993,10 @@ function liveSessionCountForNode(components: DaemonComponents, meshId: string, n
952
993
  const settings = state.settings as Record<string, unknown> || {};
953
994
  if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
954
995
  const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
955
- 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;
956
1000
  const status = readNonEmptyString(state.status).toLowerCase();
957
1001
  return !isTerminalSessionStatus(status);
958
1002
  }).length;
@@ -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
  });