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

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,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
+ }
@@ -972,9 +972,36 @@ export class CliProviderInstance implements ProviderInstance {
972
972
  * terminal state. Leaving meshNodeFor pinned would route this session's
973
973
  * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
974
974
  * coordinator as if they were task completions.
975
+ *
976
+ * MESHID-DROP-ON-DETACH (Fix C): a coordinator-LAUNCHED worker session
977
+ * (launchedByCoordinator) holds its mesh membership (meshNodeFor / meshNodeId /
978
+ * meshCoordinatorDaemonId) at the SESSION level — set once at launch
979
+ * (mesh_launch_session / queue auto-launch), independent of any single task.
980
+ * The original detach wiped meshNodeFor + meshNodeId together with the
981
+ * task-level meshActiveTaskId, so the FIRST task completion stripped the
982
+ * membership and EVERY subsequent completion forwarded with meshId absent —
983
+ * resolveWorkerDelegateRouting fell to mesh_unresolved and the coordinator
984
+ * rejected the forward "meshId required". For a launched member we therefore
985
+ * clear ONLY the task-level marker (meshActiveTaskId) and preserve the
986
+ * session-level membership so its next task's completion still resolves.
987
+ * A task-less ad-hoc turn on a preserved-membership session is NOT misrouted:
988
+ * its completion carries no taskId and the session holds no active assignment,
989
+ * so the forwarder's WARMUPGAP guard skips the dispatch-row flip (it only
990
+ * injects a benign task-less notification). A NON-launched session (a plain CLI
991
+ * session adopted by mesh_send_task --direct, launchedByCoordinator falsy)
992
+ * keeps the original full clear so an ad-hoc session is never left pinned.
975
993
  */
976
994
  detachMeshAssignment(): void {
977
995
  if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
996
+ // Session-level member: keep membership, drop only the task-level marker.
997
+ if (this.settings.launchedByCoordinator === true) {
998
+ if (!this.settings.meshActiveTaskId) return;
999
+ const { meshActiveTaskId, ...rest } = this.settings;
1000
+ void meshActiveTaskId;
1001
+ this.settings = rest;
1002
+ this.adapter.updateRuntimeSettings?.(this.settings);
1003
+ return;
1004
+ }
978
1005
  const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
979
1006
  void meshNodeFor; void meshActiveTaskId;
980
1007
  // WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
@@ -352,6 +352,114 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
352
352
  maxTaskRetries: 1,
353
353
  };
354
354
 
355
+ // ─── Policy normalization (single source of truth) ──────────────────────────
356
+ //
357
+ // Every mesh policy passes through mergeAndNormalizePolicy exactly once on write
358
+ // (createMesh/updateMesh) and again whenever a policy is materialized for display
359
+ // or scheduling. Co-locating the default constant, the per-field normalizers, and
360
+ // the merge here keeps the three former layers (DEFAULT_MESH_POLICY, the merge in
361
+ // mesh-config, and the scattered field clamps) from drifting apart. The function
362
+ // is idempotent: feeding it an already-normalized policy yields the same object.
363
+
364
+ const SESSION_CLEANUP_MODES = new Set<RepoMeshSessionCleanupMode>([
365
+ 'preserve', 'stop', 'delete_stopped', 'stop_and_delete',
366
+ ]);
367
+ const SPAWNED_SESSION_VISIBILITY_MODES = new Set<RepoMeshSpawnedSessionVisibility>([
368
+ 'visible', 'hidden',
369
+ ]);
370
+ const DIRTY_WORKSPACE_BEHAVIORS = new Set<RepoMeshPolicy['dirtyWorkspaceBehavior']>([
371
+ 'block', 'warn', 'checkpoint_then_continue',
372
+ ]);
373
+
374
+ /** Min/max bounds for the global write-task parallel cap. */
375
+ export const MESH_MAX_PARALLEL_TASKS_MIN = 1;
376
+ export const MESH_MAX_PARALLEL_TASKS_MAX = 8;
377
+
378
+ /**
379
+ * Resolve the effective global write-task parallel cap from a raw policy value,
380
+ * clamped to [MESH_MAX_PARALLEL_TASKS_MIN, MESH_MAX_PARALLEL_TASKS_MAX] and
381
+ * defaulting to DEFAULT_MESH_POLICY.maxParallelTasks for a missing/NaN value.
382
+ * Both the config write path and the runtime scheduler read the cap through this
383
+ * helper so they can never disagree on what "max parallel" means.
384
+ */
385
+ export function resolveMaxParallelTasks(value: unknown): number {
386
+ const n = Number(value);
387
+ if (!Number.isFinite(n)) return DEFAULT_MESH_POLICY.maxParallelTasks;
388
+ return Math.max(MESH_MAX_PARALLEL_TASKS_MIN, Math.min(MESH_MAX_PARALLEL_TASKS_MAX, Math.floor(n)));
389
+ }
390
+
391
+ /**
392
+ * Normalize an autoFastForward sub-policy, filling defaults and dropping an
393
+ * invalid maxBehind. Mirrors the (previously mesh-config-local) shape so the merge
394
+ * always emits a fully-populated, valid autoFastForward object.
395
+ */
396
+ export function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
397
+ const record = value && typeof value === 'object' && !Array.isArray(value)
398
+ ? value as Record<string, unknown>
399
+ : {};
400
+ const maxBehind = Number(record.maxBehind);
401
+ return {
402
+ enabled: record.enabled !== false,
403
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
404
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Canonical merge+normalize for a RepoMeshPolicy. Layers (lowest→highest):
410
+ * DEFAULT_MESH_POLICY → base (existing persisted policy) → patch (incoming change),
411
+ * then applies every per-field normalizer so the result is always valid regardless
412
+ * of what a hand-edited meshes.json or a partial patch contained.
413
+ *
414
+ * Persistence economy is preserved: schedulingStrategy is dropped when it
415
+ * normalizes to the 'first_eligible' default, and autoConvergeCodeChange is dropped
416
+ * unless explicitly true — so an untouched meshes.json stays byte-for-byte the same.
417
+ */
418
+ export function mergeAndNormalizePolicy(
419
+ base: RepoMeshPolicy | undefined,
420
+ patch: Partial<RepoMeshPolicy> | undefined,
421
+ ): RepoMeshPolicy {
422
+ const autoFastForward = normalizeAutoFastForwardPolicy({
423
+ ...DEFAULT_MESH_POLICY.autoFastForward,
424
+ ...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
425
+ ...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
426
+ });
427
+ const policy: RepoMeshPolicy = {
428
+ ...DEFAULT_MESH_POLICY,
429
+ ...(base || {}),
430
+ ...(patch || {}),
431
+ autoFastForward,
432
+ };
433
+ if (!DIRTY_WORKSPACE_BEHAVIORS.has(policy.dirtyWorkspaceBehavior)) {
434
+ policy.dirtyWorkspaceBehavior = 'warn';
435
+ }
436
+ policy.maxParallelTasks = resolveMaxParallelTasks(policy.maxParallelTasks);
437
+ policy.allowAutoPublishSubmoduleMainCommits = policy.allowAutoPublishSubmoduleMainCommits === true;
438
+ if (!SESSION_CLEANUP_MODES.has(policy.sessionCleanupOnNodeRemove as RepoMeshSessionCleanupMode)) {
439
+ policy.sessionCleanupOnNodeRemove = 'preserve';
440
+ }
441
+ if (!SPAWNED_SESSION_VISIBILITY_MODES.has(policy.spawnedSessionVisibility as RepoMeshSpawnedSessionVisibility)) {
442
+ policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
443
+ }
444
+ // Load-balancing: normalize the scheduling strategy so an invalid/blank value
445
+ // falls back to 'first_eligible' (strict no-change). Only persist the field when
446
+ // it is explicitly a non-default value to keep existing meshes.json untouched.
447
+ const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
448
+ if (normalizedStrategy === 'first_eligible') {
449
+ delete policy.schedulingStrategy;
450
+ } else {
451
+ policy.schedulingStrategy = normalizedStrategy;
452
+ }
453
+ // Convergence routing: strict opt-in (default false). Only persist when explicitly
454
+ // enabled so existing meshes.json stays byte-for-byte untouched.
455
+ if (policy.autoConvergeCodeChange === true) {
456
+ policy.autoConvergeCodeChange = true;
457
+ } else {
458
+ delete policy.autoConvergeCodeChange;
459
+ }
460
+ return policy;
461
+ }
462
+
355
463
  /**
356
464
  * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
357
465
  * governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
@@ -585,6 +693,44 @@ export interface LocalMeshNodeEntry {
585
693
 
586
694
  // ─── Mesh Status (runtime, not persisted) ───────
587
695
 
696
+ /**
697
+ * Per-(node, provider) cap + consumption, as surfaced on a node's scheduling
698
+ * status. Wire-shape mirror of MeshNodeProviderSchedulingRuntime.
699
+ */
700
+ export interface RepoMeshNodeProviderSchedulingStatus {
701
+ providerType: string;
702
+ maxParallel?: number;
703
+ activeAssigned: number;
704
+ capReached: boolean;
705
+ }
706
+
707
+ /**
708
+ * Per-node scheduling runtime exposed on RepoMeshNodeStatus.scheduling. Carried in
709
+ * full by verbose mesh_status; compact mesh_status sends only {load, capReached}.
710
+ */
711
+ export interface RepoMeshNodeSchedulingStatus {
712
+ load: number;
713
+ schedulingPriority?: number;
714
+ maxConcurrentSessions?: number;
715
+ providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
716
+ capReached: boolean;
717
+ capReasons?: string[];
718
+ }
719
+
720
+ /**
721
+ * Mesh-level scheduling rollup exposed on RepoMeshStatus.scheduling: which tie-break
722
+ * strategy is live and how much of the global parallel caps is consumed.
723
+ */
724
+ export interface RepoMeshSchedulingStatus {
725
+ strategy: RepoMeshSchedulingStrategy;
726
+ maxParallelTasks: number;
727
+ maxReadonlyParallelTasks: number;
728
+ activeWriteAssigned: number;
729
+ activeReadonlyAssigned: number;
730
+ globalWriteCapReached: boolean;
731
+ globalReadonlyCapReached: boolean;
732
+ }
733
+
588
734
  export interface RepoMeshStatus {
589
735
  meshId: string;
590
736
  meshName: string;
@@ -595,6 +741,11 @@ export interface RepoMeshStatus {
595
741
  nodes: RepoMeshNodeStatus[];
596
742
  queue?: RepoMeshQueueStatus;
597
743
  ledger?: RepoMeshLedgerStatus;
744
+ /**
745
+ * Mesh-level scheduling rollup (strategy + global cap consumption). Omitted by
746
+ * daemons predating the scheduling-runtime exposure — treat as optional.
747
+ */
748
+ scheduling?: RepoMeshSchedulingStatus;
598
749
  /**
599
750
  * Mission summaries for the dashboard overview. Active/paused missions plus a
600
751
  * capped, newest-first slice of completed/abandoned history. Omitted by older
@@ -674,6 +825,19 @@ export interface RepoMeshNodeStatus {
674
825
  lastSeenAt?: string;
675
826
  updatedAt?: string;
676
827
  connection?: RepoMeshPeerConnectionStatus;
828
+ /**
829
+ * Per-node scheduling runtime (load / priority / provider caps / claim-block
830
+ * reasons). Verbose mesh_status carries the full shape; compact carries only
831
+ * {load, capReached}. Omitted by daemons predating the exposure.
832
+ */
833
+ scheduling?: RepoMeshNodeSchedulingStatus;
834
+ /**
835
+ * Stale-daemon-build marker: the live daemon's build commit is a strict ancestor
836
+ * of this node's workspace HEAD (merged code not yet live). Best-effort, set by
837
+ * mesh_status when the git probe reports daemonBuildBehind; shape is daemon-defined
838
+ * (scope/isDaemonAffecting flags). Omitted when the build is current.
839
+ */
840
+ staleDaemonBuild?: Record<string, unknown>;
677
841
  error?: string;
678
842
  }
679
843