@adhdev/daemon-core 0.9.82-rc.412 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +449 -387
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +448 -387
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-task-inflight.d.ts +46 -0
- package/package.json +2 -2
- package/src/commands/med-family/mesh-queue.ts +14 -0
- package/src/index.ts +1 -1
- package/src/mesh/mesh-queue-assignment.ts +54 -10
- package/src/mesh/mesh-task-inflight.ts +70 -0
- package/src/mesh/mesh-work-queue.ts +25 -0
|
@@ -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.
|
|
3
|
+
"version": "0.9.82-rc.413",
|
|
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.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.413",
|
|
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 =
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|