@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.
- package/dist/commands/med-family/mesh-crud.d.ts +27 -0
- package/dist/config/mesh-json-config.d.ts +31 -131
- package/dist/config/repo-settings.d.ts +77 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +817 -802
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +804 -791
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-task-inflight.d.ts +46 -0
- package/package.json +2 -2
- package/src/commands/high-family/mesh-coordinator-launch.ts +9 -4
- package/src/commands/med-family/mesh-crud.ts +85 -8
- package/src/commands/med-family/mesh-queue.ts +14 -0
- package/src/config/mesh-json-config.ts +40 -281
- package/src/config/repo-settings.ts +111 -0
- package/src/index.ts +6 -1
- package/src/mesh/mesh-ledger.ts +10 -0
- package/src/mesh/mesh-missions.ts +86 -1
- package/src/mesh/mesh-queue-assignment.ts +69 -61
- package/src/mesh/mesh-task-inflight.ts +70 -0
- package/src/mesh/mesh-work-queue.ts +25 -0
|
@@ -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
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
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 (
|
|
1146
|
-
const maxReadonlyParallelTasks = resolveMaxReadonlyParallelTasks(maxParallelTasks
|
|
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
|
});
|