@adhdev/daemon-core 0.9.82-rc.380 → 0.9.82-rc.381

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.
@@ -128,6 +128,18 @@ export declare class MeshRuntimeStore {
128
128
  updatedAt: string;
129
129
  }>;
130
130
  updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
131
+ /**
132
+ * MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
133
+ * non-terminal direct dispatch a session owns. Returns the task_id only when the session
134
+ * holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
135
+ * lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
136
+ * unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
137
+ * two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
138
+ * return null and the caller MUST NOT fall back to the session_id sweep that would flip a
139
+ * sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
140
+ * only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
141
+ */
142
+ getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null;
131
143
  cleanupTerminalDirectDispatches(olderThanMs: number): void;
132
144
  deleteDirectDispatches(meshId: string): void;
133
145
  /**
@@ -0,0 +1,78 @@
1
+ import type { RepoMeshNodePolicy, RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
2
+ import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
3
+ /** Per-(node, provider) cap and its current consumption. */
4
+ export interface MeshNodeProviderSchedulingRuntime {
5
+ providerType: string;
6
+ /** Declared maxParallel cap for this (node, provider). Omitted when uncapped. */
7
+ maxParallel?: number;
8
+ /** Active (status='assigned') tasks on this node claimed by this provider. */
9
+ activeAssigned: number;
10
+ /** True when activeAssigned has reached maxParallel (a further claim is refused). */
11
+ capReached: boolean;
12
+ }
13
+ /** Per-node scheduling-runtime projection. */
14
+ export interface MeshNodeSchedulingRuntime {
15
+ nodeId: string;
16
+ /** Active (status='assigned') task count on this node — the least-loaded rank key. */
17
+ load: number;
18
+ /** Soft scheduling priority (PRIORITY rank key; higher = preferred). */
19
+ schedulingPriority: number;
20
+ /** Per-node concurrent-session cap, when configured. */
21
+ maxConcurrentSessions?: number;
22
+ /** Per-(node, provider) caps + consumption, when providerRoles declares any. */
23
+ providerRoles?: MeshNodeProviderSchedulingRuntime[];
24
+ /**
25
+ * True when the node currently cannot claim a NEW write (non-readonly) task —
26
+ * either the global write cap is exhausted or a node-local gate (active write
27
+ * assignment / a fully-consumed provider cap / session cap) blocks it.
28
+ */
29
+ capReached: boolean;
30
+ /** Structured reasons backing capReached (empty when the node can take work). */
31
+ capReasons: string[];
32
+ }
33
+ /** Mesh-level scheduling-runtime projection. */
34
+ export interface MeshSchedulingRuntime {
35
+ /** Resolved tie-break strategy (defaults to 'first_eligible'). */
36
+ strategy: RepoMeshSchedulingStrategy;
37
+ /** Effective global write-task parallel cap (clamped). */
38
+ maxParallelTasks: number;
39
+ /** Derived read-only diagnosis cap (max(2, 2× write cap)) — mirrors the claim path. */
40
+ maxReadonlyParallelTasks: number;
41
+ /** Current global write (non-readonly) assigned-task load. */
42
+ activeWriteAssigned: number;
43
+ /** Current global read-only assigned-task load. */
44
+ activeReadonlyAssigned: number;
45
+ /** True when the global write cap is exhausted (no new write task can launch). */
46
+ globalWriteCapReached: boolean;
47
+ /** True when the read-only diagnosis cap is exhausted. */
48
+ globalReadonlyCapReached: boolean;
49
+ /** Per-node projections, in mesh config order. */
50
+ nodes: MeshNodeSchedulingRuntime[];
51
+ }
52
+ interface MeshLike {
53
+ policy?: {
54
+ schedulingStrategy?: unknown;
55
+ maxParallelTasks?: unknown;
56
+ } | null;
57
+ nodes?: Array<{
58
+ id?: string;
59
+ nodeId?: string;
60
+ node_id?: string;
61
+ policy?: RepoMeshNodePolicy | null;
62
+ isLocalWorktree?: boolean;
63
+ }> | null;
64
+ }
65
+ /**
66
+ * Build the scheduling-runtime projection for a mesh from its config + a snapshot of
67
+ * its work queue. Pure and side-effect free — safe to call on any read path.
68
+ *
69
+ * The derivation deliberately mirrors maybeAutoLaunchOneQueueSession's gates so the
70
+ * exposed "capReached/capReasons" match why a real claim would be refused:
71
+ * • global write cap → activeWriteAssigned >= maxParallelTasks
72
+ * • node write conflict→ a node already holding an assigned write task (worktree isolation)
73
+ * • provider cap → a (node, provider) at its declared maxParallel
74
+ * It does NOT re-evaluate eligibility/required-tags (those are task-specific); it only
75
+ * reports the capacity picture, which is what an operator needs to read load balance.
76
+ */
77
+ export declare function buildMeshSchedulingRuntime(mesh: MeshLike | null | undefined, queue: MeshWorkQueueEntry[]): MeshSchedulingRuntime;
78
+ export {};
@@ -279,6 +279,34 @@ export interface RepoMeshNodePolicy {
279
279
  initSubmodulesOnClone?: boolean;
280
280
  }
281
281
  export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
282
+ /** Min/max bounds for the global write-task parallel cap. */
283
+ export declare const MESH_MAX_PARALLEL_TASKS_MIN = 1;
284
+ export declare const MESH_MAX_PARALLEL_TASKS_MAX = 8;
285
+ /**
286
+ * Resolve the effective global write-task parallel cap from a raw policy value,
287
+ * clamped to [MESH_MAX_PARALLEL_TASKS_MIN, MESH_MAX_PARALLEL_TASKS_MAX] and
288
+ * defaulting to DEFAULT_MESH_POLICY.maxParallelTasks for a missing/NaN value.
289
+ * Both the config write path and the runtime scheduler read the cap through this
290
+ * helper so they can never disagree on what "max parallel" means.
291
+ */
292
+ export declare function resolveMaxParallelTasks(value: unknown): number;
293
+ /**
294
+ * Normalize an autoFastForward sub-policy, filling defaults and dropping an
295
+ * invalid maxBehind. Mirrors the (previously mesh-config-local) shape so the merge
296
+ * always emits a fully-populated, valid autoFastForward object.
297
+ */
298
+ export declare function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']>;
299
+ /**
300
+ * Canonical merge+normalize for a RepoMeshPolicy. Layers (lowest→highest):
301
+ * DEFAULT_MESH_POLICY → base (existing persisted policy) → patch (incoming change),
302
+ * then applies every per-field normalizer so the result is always valid regardless
303
+ * of what a hand-edited meshes.json or a partial patch contained.
304
+ *
305
+ * Persistence economy is preserved: schedulingStrategy is dropped when it
306
+ * normalizes to the 'first_eligible' default, and autoConvergeCodeChange is dropped
307
+ * unless explicitly true — so an untouched meshes.json stays byte-for-byte the same.
308
+ */
309
+ export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy;
282
310
  /**
283
311
  * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
284
312
  * governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
@@ -464,6 +492,41 @@ export interface LocalMeshNodeEntry {
464
492
  relatedRepos?: RepoMeshRelatedRepo[];
465
493
  role?: RepoMeshDaemonRole;
466
494
  }
495
+ /**
496
+ * Per-(node, provider) cap + consumption, as surfaced on a node's scheduling
497
+ * status. Wire-shape mirror of MeshNodeProviderSchedulingRuntime.
498
+ */
499
+ export interface RepoMeshNodeProviderSchedulingStatus {
500
+ providerType: string;
501
+ maxParallel?: number;
502
+ activeAssigned: number;
503
+ capReached: boolean;
504
+ }
505
+ /**
506
+ * Per-node scheduling runtime exposed on RepoMeshNodeStatus.scheduling. Carried in
507
+ * full by verbose mesh_status; compact mesh_status sends only {load, capReached}.
508
+ */
509
+ export interface RepoMeshNodeSchedulingStatus {
510
+ load: number;
511
+ schedulingPriority?: number;
512
+ maxConcurrentSessions?: number;
513
+ providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
514
+ capReached: boolean;
515
+ capReasons?: string[];
516
+ }
517
+ /**
518
+ * Mesh-level scheduling rollup exposed on RepoMeshStatus.scheduling: which tie-break
519
+ * strategy is live and how much of the global parallel caps is consumed.
520
+ */
521
+ export interface RepoMeshSchedulingStatus {
522
+ strategy: RepoMeshSchedulingStrategy;
523
+ maxParallelTasks: number;
524
+ maxReadonlyParallelTasks: number;
525
+ activeWriteAssigned: number;
526
+ activeReadonlyAssigned: number;
527
+ globalWriteCapReached: boolean;
528
+ globalReadonlyCapReached: boolean;
529
+ }
467
530
  export interface RepoMeshStatus {
468
531
  meshId: string;
469
532
  meshName: string;
@@ -474,6 +537,11 @@ export interface RepoMeshStatus {
474
537
  nodes: RepoMeshNodeStatus[];
475
538
  queue?: RepoMeshQueueStatus;
476
539
  ledger?: RepoMeshLedgerStatus;
540
+ /**
541
+ * Mesh-level scheduling rollup (strategy + global cap consumption). Omitted by
542
+ * daemons predating the scheduling-runtime exposure — treat as optional.
543
+ */
544
+ scheduling?: RepoMeshSchedulingStatus;
477
545
  /**
478
546
  * Mission summaries for the dashboard overview. Active/paused missions plus a
479
547
  * capped, newest-first slice of completed/abandoned history. Omitted by older
@@ -545,6 +613,19 @@ export interface RepoMeshNodeStatus {
545
613
  lastSeenAt?: string;
546
614
  updatedAt?: string;
547
615
  connection?: RepoMeshPeerConnectionStatus;
616
+ /**
617
+ * Per-node scheduling runtime (load / priority / provider caps / claim-block
618
+ * reasons). Verbose mesh_status carries the full shape; compact carries only
619
+ * {load, capReached}. Omitted by daemons predating the exposure.
620
+ */
621
+ scheduling?: RepoMeshNodeSchedulingStatus;
622
+ /**
623
+ * Stale-daemon-build marker: the live daemon's build commit is a strict ancestor
624
+ * of this node's workspace HEAD (merged code not yet live). Best-effort, set by
625
+ * mesh_status when the git probe reports daemonBuildBehind; shape is daemon-defined
626
+ * (scope/isDaemonAffecting flags). Omitted when the build is current.
627
+ */
628
+ staleDaemonBuild?: Record<string, unknown>;
548
629
  error?: string;
549
630
  }
550
631
  export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.380",
3
+ "version": "0.9.82-rc.381",
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.380",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.381",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -22,7 +22,7 @@ import type {
22
22
  RepoMeshHostMetadata,
23
23
  RepoMeshDaemonRole,
24
24
  } from '../repo-mesh-types.js';
25
- import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
25
+ import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
26
26
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
27
27
 
28
28
  // ─── Persistence ────────────────────────────────
@@ -151,63 +151,11 @@ export function normalizeRepoIdentity(remoteUrl: string): string {
151
151
 
152
152
  // ─── CRUD Operations ────────────────────────────
153
153
 
154
- const SESSION_CLEANUP_MODES = new Set(['preserve', 'stop', 'delete_stopped', 'stop_and_delete']);
155
- const SPAWNED_SESSION_VISIBILITY_MODES = new Set(['visible', 'hidden']);
156
-
157
- function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy {
158
- const autoFastForward = normalizeAutoFastForwardPolicy({
159
- ...DEFAULT_MESH_POLICY.autoFastForward,
160
- ...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
161
- ...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
162
- });
163
- const policy: RepoMeshPolicy = {
164
- ...DEFAULT_MESH_POLICY,
165
- ...(base || {}),
166
- ...(patch || {}),
167
- autoFastForward,
168
- };
169
- if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
170
- policy.dirtyWorkspaceBehavior = 'warn';
171
- }
172
- const maxParallelTasks = Number(policy.maxParallelTasks);
173
- policy.maxParallelTasks = Number.isFinite(maxParallelTasks) ? Math.max(1, Math.min(8, Math.floor(maxParallelTasks))) : 2;
174
- policy.allowAutoPublishSubmoduleMainCommits = policy.allowAutoPublishSubmoduleMainCommits === true;
175
- if (!SESSION_CLEANUP_MODES.has(String(policy.sessionCleanupOnNodeRemove))) {
176
- policy.sessionCleanupOnNodeRemove = 'preserve';
177
- }
178
- if (!SPAWNED_SESSION_VISIBILITY_MODES.has(String(policy.spawnedSessionVisibility))) {
179
- policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
180
- }
181
- // Load-balancing: normalize the scheduling strategy so an invalid/blank value
182
- // falls back to 'first_eligible' (strict no-change). Only persist the field when
183
- // it is explicitly a non-default value to keep existing meshes.json untouched.
184
- const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
185
- if (normalizedStrategy === 'first_eligible') {
186
- delete policy.schedulingStrategy;
187
- } else {
188
- policy.schedulingStrategy = normalizedStrategy;
189
- }
190
- // Convergence routing: strict opt-in (default false). Only persist when explicitly
191
- // enabled so existing meshes.json stays byte-for-byte untouched.
192
- if (policy.autoConvergeCodeChange === true) {
193
- policy.autoConvergeCodeChange = true;
194
- } else {
195
- delete policy.autoConvergeCodeChange;
196
- }
197
- return policy;
198
- }
199
-
200
- function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
201
- const record = value && typeof value === 'object' && !Array.isArray(value)
202
- ? value as Record<string, unknown>
203
- : {};
204
- const maxBehind = Number(record.maxBehind);
205
- return {
206
- enabled: record.enabled !== false,
207
- ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
208
- requireCleanSubmodules: record.requireCleanSubmodules !== false,
209
- };
210
- }
154
+ // Single source of truth for default+merge+per-field normalization is
155
+ // mergeAndNormalizePolicy in repo-mesh-types.ts. This thin alias keeps the local
156
+ // call sites (createMesh/updateMesh) reading naturally while ensuring config
157
+ // writes go through the exact same normalizer the scheduler/display paths use.
158
+ const mergeMeshPolicy = mergeAndNormalizePolicy;
211
159
 
212
160
  export function listMeshes(): LocalMeshEntry[] {
213
161
  return loadMeshConfig().meshes;
package/src/index.ts CHANGED
@@ -135,6 +135,9 @@ export type {
135
135
  RepoMeshLedgerStatus,
136
136
  MeshAsyncJobLifecycle,
137
137
  RepoMeshSchedulingStrategy,
138
+ RepoMeshSchedulingStatus,
139
+ RepoMeshNodeSchedulingStatus,
140
+ RepoMeshNodeProviderSchedulingStatus,
138
141
  } from './repo-mesh-types.js';
139
142
  export {
140
143
  DEFAULT_MESH_POLICY,
@@ -143,6 +146,12 @@ export {
143
146
  DEFAULT_MESH_SCHEDULING_STRATEGY,
144
147
  normalizeMeshSchedulingStrategy,
145
148
  resolveNodeSchedulingPriority,
149
+ resolveProviderMaxParallel,
150
+ mergeAndNormalizePolicy,
151
+ normalizeAutoFastForwardPolicy,
152
+ resolveMaxParallelTasks,
153
+ MESH_MAX_PARALLEL_TASKS_MIN,
154
+ MESH_MAX_PARALLEL_TASKS_MAX,
146
155
  MESH_CONVERGE_REFINE_TAG,
147
156
  MESH_CONVERGE_FAST_FORWARD_TAG,
148
157
  resolveAutoConvergeCodeChange,
@@ -246,6 +255,10 @@ export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary,
246
255
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
247
256
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
248
257
 
258
+ // ── Mesh Scheduling Runtime (observability projection) ──
259
+ export { buildMeshSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
260
+ export type { MeshSchedulingRuntime, MeshNodeSchedulingRuntime, MeshNodeProviderSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
261
+
249
262
  // ── Mesh Host Ownership ──
250
263
  export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHostOwner, normalizeMeshDaemonRole, requireMeshHostQueueOwner, resolveMeshHostStatus } from './mesh/mesh-host-ownership.js';
251
264
 
@@ -31,7 +31,7 @@ import type {
31
31
  RepoMeshStatus,
32
32
  RepoMeshNodeStatus,
33
33
  } from '../repo-mesh-types.js';
34
- import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
34
+ import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
35
35
 
36
36
  /**
37
37
  * Cheap, locally-derived "what just happened" snapshot for the coordinator
@@ -196,7 +196,7 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
196
196
  if (operatingNotes) sections.push(operatingNotes);
197
197
 
198
198
  // ── Policy ──
199
- sections.push(buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }));
199
+ sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
200
200
 
201
201
  // ── Tools ──
202
202
  sections.push(TOOLS_SECTION);
@@ -278,7 +278,7 @@ function expandPromptPlaceholders(template: string, ctx: CoordinatorPromptContex
278
278
  mission: ctx.missionSection?.trim() || '',
279
279
  recentActivity: buildRecentActivitySection(ctx.recentActivity) || '',
280
280
  operatingNotes: buildOperatingNotesSection(ctx.operatingNotes) || '',
281
- policy: buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }),
281
+ policy: buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)),
282
282
  tools: TOOLS_SECTION,
283
283
  workflow: WORKFLOW_SECTION,
284
284
  rules: buildRulesSection(coordinatorCliType),
@@ -886,8 +886,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
886
886
  // sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
887
887
  // hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
888
888
  // acks below are bound to actual deliveries and stay a no-op for a warmup session).
889
- if (startedTaskId || sessionHasActiveAssignment(args.meshId, sessionId)) {
889
+ //
890
+ // MESH-DISPATCH-MISROUTE (fix 3, consumer residual): when the event carries no taskId
891
+ // (a legacy/relayed worker whose producer never stamped meshActiveTaskId) but the
892
+ // session owns EXACTLY ONE active dispatch, resolve that row's taskId and flip it by PK
893
+ // instead of the session_id sweep — the sweep flips every non-terminal row for the
894
+ // session ("may flip a sibling dispatch row"). With ≥2 active rows the owner is
895
+ // ambiguous, so resolvedAckTaskId stays undefined and we DROP the ack rather than
896
+ // mis-flip a sibling (the genuine ack arrives once the producer/reconcile names a task).
897
+ if (startedTaskId) {
890
898
  updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
899
+ } else if (sessionHasActiveAssignment(args.meshId, sessionId)) {
900
+ const soleTaskId = (() => {
901
+ try { return MeshRuntimeStore.getInstance().getSoleActiveDirectDispatchTaskId(args.meshId, sessionId); }
902
+ catch { return null; }
903
+ })();
904
+ if (soleTaskId) {
905
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked', soleTaskId);
906
+ }
891
907
  }
892
908
  const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
893
909
  try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
@@ -903,6 +903,29 @@ export class MeshRuntimeStore {
903
903
  `).run({ status, meshId, sessionId, updatedAt: now });
904
904
  }
905
905
 
906
+ /**
907
+ * MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
908
+ * non-terminal direct dispatch a session owns. Returns the task_id only when the session
909
+ * holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
910
+ * lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
911
+ * unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
912
+ * two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
913
+ * return null and the caller MUST NOT fall back to the session_id sweep that would flip a
914
+ * sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
915
+ * only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
916
+ */
917
+ getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null {
918
+ if (!sessionId) return null;
919
+ const rows = this.db.prepare(`
920
+ SELECT task_id FROM mesh_direct_dispatches
921
+ WHERE mesh_id = ? AND session_id = ?
922
+ AND status NOT IN ('completed', 'failed', 'stale')
923
+ `).all(meshId, sessionId) as Array<{ task_id: string }>;
924
+ if (rows.length !== 1) return null;
925
+ const taskId = typeof rows[0]?.task_id === 'string' ? rows[0].task_id.trim() : '';
926
+ return taskId || null;
927
+ }
928
+
906
929
  cleanupTerminalDirectDispatches(olderThanMs: number): void {
907
930
  const cutoff = new Date(Date.now() - olderThanMs).toISOString();
908
931
  this.db.prepare(`
@@ -0,0 +1,199 @@
1
+ // Mesh scheduling-runtime view — a read-only, derived projection of how the queue
2
+ // scheduler currently sees the mesh: which tie-break strategy is live, the global
3
+ // parallel caps and how much of them is consumed, and per-node load / priority /
4
+ // per-(node, provider) caps plus a structured "why this node can't take more write
5
+ // work right now" reason set.
6
+ //
7
+ // This is OBSERVABILITY ONLY. It re-derives the same numbers the live claim path
8
+ // (maybeAutoLaunchOneQueueSession → orderEligibleNodes → claimNextQueueTask) acts
9
+ // on, but never mutates anything and never drives a scheduling decision. It is built
10
+ // from the in-memory mesh config + the queue snapshot, so callers (mesh_status) get a
11
+ // consistent picture without touching the SQLite claim transaction. Keeping the
12
+ // derivation here — next to the constants it mirrors — means the exposed numbers and
13
+ // the enforced numbers stay defined in one module family.
14
+
15
+ import type { RepoMeshNodePolicy, RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
16
+ import {
17
+ normalizeMeshSchedulingStrategy,
18
+ resolveMaxParallelTasks,
19
+ resolveNodeSchedulingPriority,
20
+ resolveProviderMaxParallel,
21
+ } from '../repo-mesh-types.js';
22
+ import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
23
+ import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
24
+
25
+ /** Per-(node, provider) cap and its current consumption. */
26
+ export interface MeshNodeProviderSchedulingRuntime {
27
+ providerType: string;
28
+ /** Declared maxParallel cap for this (node, provider). Omitted when uncapped. */
29
+ maxParallel?: number;
30
+ /** Active (status='assigned') tasks on this node claimed by this provider. */
31
+ activeAssigned: number;
32
+ /** True when activeAssigned has reached maxParallel (a further claim is refused). */
33
+ capReached: boolean;
34
+ }
35
+
36
+ /** Per-node scheduling-runtime projection. */
37
+ export interface MeshNodeSchedulingRuntime {
38
+ nodeId: string;
39
+ /** Active (status='assigned') task count on this node — the least-loaded rank key. */
40
+ load: number;
41
+ /** Soft scheduling priority (PRIORITY rank key; higher = preferred). */
42
+ schedulingPriority: number;
43
+ /** Per-node concurrent-session cap, when configured. */
44
+ maxConcurrentSessions?: number;
45
+ /** Per-(node, provider) caps + consumption, when providerRoles declares any. */
46
+ providerRoles?: MeshNodeProviderSchedulingRuntime[];
47
+ /**
48
+ * True when the node currently cannot claim a NEW write (non-readonly) task —
49
+ * either the global write cap is exhausted or a node-local gate (active write
50
+ * assignment / a fully-consumed provider cap / session cap) blocks it.
51
+ */
52
+ capReached: boolean;
53
+ /** Structured reasons backing capReached (empty when the node can take work). */
54
+ capReasons: string[];
55
+ }
56
+
57
+ /** Mesh-level scheduling-runtime projection. */
58
+ export interface MeshSchedulingRuntime {
59
+ /** Resolved tie-break strategy (defaults to 'first_eligible'). */
60
+ strategy: RepoMeshSchedulingStrategy;
61
+ /** Effective global write-task parallel cap (clamped). */
62
+ maxParallelTasks: number;
63
+ /** Derived read-only diagnosis cap (max(2, 2× write cap)) — mirrors the claim path. */
64
+ maxReadonlyParallelTasks: number;
65
+ /** Current global write (non-readonly) assigned-task load. */
66
+ activeWriteAssigned: number;
67
+ /** Current global read-only assigned-task load. */
68
+ activeReadonlyAssigned: number;
69
+ /** True when the global write cap is exhausted (no new write task can launch). */
70
+ globalWriteCapReached: boolean;
71
+ /** True when the read-only diagnosis cap is exhausted. */
72
+ globalReadonlyCapReached: boolean;
73
+ /** Per-node projections, in mesh config order. */
74
+ nodes: MeshNodeSchedulingRuntime[];
75
+ }
76
+
77
+ interface MeshLike {
78
+ policy?: { schedulingStrategy?: unknown; maxParallelTasks?: unknown } | null;
79
+ nodes?: Array<{ id?: string; nodeId?: string; node_id?: string; policy?: RepoMeshNodePolicy | null; isLocalWorktree?: boolean }> | null;
80
+ }
81
+
82
+ function isReadonly(task: MeshWorkQueueEntry): boolean {
83
+ return task.taskMode === 'live_debug_readonly';
84
+ }
85
+
86
+ function isAssigned(task: MeshWorkQueueEntry): boolean {
87
+ return task.status === 'assigned';
88
+ }
89
+
90
+ /**
91
+ * Build the scheduling-runtime projection for a mesh from its config + a snapshot of
92
+ * its work queue. Pure and side-effect free — safe to call on any read path.
93
+ *
94
+ * The derivation deliberately mirrors maybeAutoLaunchOneQueueSession's gates so the
95
+ * exposed "capReached/capReasons" match why a real claim would be refused:
96
+ * • global write cap → activeWriteAssigned >= maxParallelTasks
97
+ * • node write conflict→ a node already holding an assigned write task (worktree isolation)
98
+ * • provider cap → a (node, provider) at its declared maxParallel
99
+ * It does NOT re-evaluate eligibility/required-tags (those are task-specific); it only
100
+ * reports the capacity picture, which is what an operator needs to read load balance.
101
+ */
102
+ export function buildMeshSchedulingRuntime(
103
+ mesh: MeshLike | null | undefined,
104
+ queue: MeshWorkQueueEntry[],
105
+ ): MeshSchedulingRuntime {
106
+ const strategy = normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
107
+ const maxParallelTasks = resolveMaxParallelTasks(mesh?.policy?.maxParallelTasks);
108
+ // Read-only diagnoses are exempt from the write cap and get their own higher
109
+ // safety cap (2× the write cap, floor 2) — identical to the claim path.
110
+ const maxReadonlyParallelTasks = Math.max(2, maxParallelTasks * 2);
111
+
112
+ const assignedTasks = (Array.isArray(queue) ? queue : []).filter(isAssigned);
113
+ const activeWriteAssigned = assignedTasks.filter(t => !isReadonly(t)).length;
114
+ const activeReadonlyAssigned = assignedTasks.filter(isReadonly).length;
115
+ const globalWriteCapReached = activeWriteAssigned >= maxParallelTasks;
116
+ const globalReadonlyCapReached = activeReadonlyAssigned >= maxReadonlyParallelTasks;
117
+
118
+ // Pre-bucket assigned tasks by node so per-node load and per-provider counts are
119
+ // a single pass rather than O(nodes × queue).
120
+ const writeAssignedByNode = new Map<string, number>();
121
+ const assignedByNode = new Map<string, number>();
122
+ const providerCountByNode = new Map<string, Map<string, number>>();
123
+ for (const task of assignedTasks) {
124
+ const nodeId = typeof task.assignedNodeId === 'string' ? task.assignedNodeId.trim() : '';
125
+ if (!nodeId) continue;
126
+ assignedByNode.set(nodeId, (assignedByNode.get(nodeId) ?? 0) + 1);
127
+ if (!isReadonly(task)) {
128
+ writeAssignedByNode.set(nodeId, (writeAssignedByNode.get(nodeId) ?? 0) + 1);
129
+ }
130
+ const provider = typeof task.assignedProviderType === 'string' ? task.assignedProviderType : '';
131
+ if (provider) {
132
+ let byProvider = providerCountByNode.get(nodeId);
133
+ if (!byProvider) { byProvider = new Map(); providerCountByNode.set(nodeId, byProvider); }
134
+ byProvider.set(provider, (byProvider.get(provider) ?? 0) + 1);
135
+ }
136
+ }
137
+
138
+ const nodes: MeshNodeSchedulingRuntime[] = [];
139
+ for (const rawNode of (Array.isArray(mesh?.nodes) ? mesh!.nodes! : [])) {
140
+ const nodeId = normalizeMeshNodeId(rawNode);
141
+ if (!nodeId) continue;
142
+ const policy = (rawNode?.policy || undefined) as RepoMeshNodePolicy | undefined;
143
+ const load = assignedByNode.get(nodeId) ?? 0;
144
+ const schedulingPriority = resolveNodeSchedulingPriority(policy);
145
+
146
+ const capReasons: string[] = [];
147
+ if (globalWriteCapReached) capReasons.push('global_max_parallel_tasks_reached');
148
+ // Write isolation: a node already holding an assigned write task can't take another.
149
+ if ((writeAssignedByNode.get(nodeId) ?? 0) > 0) capReasons.push('node_has_active_assignment');
150
+
151
+ // Per-(node, provider) caps, with live consumption.
152
+ let providerRoles: MeshNodeProviderSchedulingRuntime[] | undefined;
153
+ const declaredRoles = Array.isArray(policy?.providerRoles) ? policy!.providerRoles! : [];
154
+ if (declaredRoles.length) {
155
+ const byProvider = providerCountByNode.get(nodeId);
156
+ providerRoles = [];
157
+ for (const role of declaredRoles) {
158
+ if (!role || typeof role !== 'object') continue;
159
+ const providerType = typeof role.providerType === 'string' ? role.providerType.trim() : '';
160
+ if (!providerType) continue;
161
+ const maxParallel = resolveProviderMaxParallel(policy, providerType);
162
+ const activeAssigned = byProvider?.get(providerType) ?? 0;
163
+ const capReached = maxParallel !== undefined && activeAssigned >= maxParallel;
164
+ providerRoles.push({
165
+ providerType,
166
+ ...(maxParallel !== undefined ? { maxParallel } : {}),
167
+ activeAssigned,
168
+ capReached,
169
+ });
170
+ if (capReached) capReasons.push(`max_provider_parallel_reached:${providerType}`);
171
+ }
172
+ if (!providerRoles.length) providerRoles = undefined;
173
+ }
174
+
175
+ const maxConcurrentSessions = Number(policy?.maxConcurrentSessions);
176
+ const hasSessionCap = Number.isFinite(maxConcurrentSessions) && maxConcurrentSessions >= 0;
177
+
178
+ nodes.push({
179
+ nodeId,
180
+ load,
181
+ schedulingPriority,
182
+ ...(hasSessionCap ? { maxConcurrentSessions: Math.floor(maxConcurrentSessions) } : {}),
183
+ ...(providerRoles ? { providerRoles } : {}),
184
+ capReached: capReasons.length > 0,
185
+ capReasons,
186
+ });
187
+ }
188
+
189
+ return {
190
+ strategy,
191
+ maxParallelTasks,
192
+ maxReadonlyParallelTasks,
193
+ activeWriteAssigned,
194
+ activeReadonlyAssigned,
195
+ globalWriteCapReached,
196
+ globalReadonlyCapReached,
197
+ nodes,
198
+ };
199
+ }