@adhdev/daemon-core 0.9.82-rc.484 → 0.9.82-rc.486

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.
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
2
2
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
3
  import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
4
4
  import { loadConfig } from '../config/config.js';
5
- import { getMesh } from '../config/mesh-config.js';
5
+ import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
@@ -15,7 +15,7 @@ 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, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } 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 { readMeshNodeDaemonId } from './mesh-node-identity.js';
@@ -92,12 +92,62 @@ export function getMeshWithCache(components: DaemonComponents, meshId: string):
92
92
  * worktree node therefore read a permanently stale 'running' here, so
93
93
  * shouldDeferDispatchForBootstrap deferred its claim forever. We now MERGE the inline
94
94
  * cache's dynamic runtime bootstrap state onto the config node (config keeps its static
95
- * fields; worktreeBootstrap is preferred from the inline cache when the inline entry
96
- * carries a status) so the gate view sees the terminal stamp. Regression-safe: when the
97
- * inline entry has no bootstrap status the config value is kept, and when bootstrap is
98
- * genuinely still 'running' (no terminal stamp yet) the gate still defers only a node
99
- * whose inline stamp has actually reached a terminal state opens the gate.
95
+ * fields; worktreeBootstrap is preferred from the inline cache) so EVERY consumer of the
96
+ * merged view not just tryAssignQueueTask's gate observes the terminal stamp.
97
+ *
98
+ * RESIDUAL-getMeshWithCache-bootstrap-overlay (precedence guard): the overlay is DIRECTIONAL
99
+ * it prefers the inline entry ONLY when the inline runtime state is actually fresher, never
100
+ * merely because the inline entry carries a status. inlineBootstrapIsFresher() (below) permits
101
+ * the overlay in exactly two cases, mirroring the mission's "terminal OR strictly newer" rule:
102
+ * (1) the inline state is TERMINAL ('complete'/'failed') while the config state is NOT — the
103
+ * markWorktreeBootstrapTerminalState synchronous stamp the async config persist has not
104
+ * yet caught up to; this is the whole point of the overlay (opens the gate).
105
+ * (2) both states are non-terminal but the inline startedAt is STRICTLY newer — a re-driven
106
+ * bootstrap whose fresher 'running' epoch the config has not observed.
107
+ * It REFUSES the overlay when the config state is already terminal and the inline state is a
108
+ * stale/non-terminal 'running' — otherwise a stale inline 'running' would MASK a genuinely
109
+ * complete config node and re-defer its claim forever (the exact anti-case this guard closes).
110
+ * And when both are 'running' with no newer epoch, the config value is kept and the gate still
111
+ * defers — the half-built-worktree → empty-session defense is preserved: only a terminal-confirmed
112
+ * inline state, never an ambiguous read, ever opens the gate.
113
+ */
114
+ const BOOTSTRAP_TERMINAL_STATUSES = new Set(['complete', 'failed']);
115
+
116
+ function bootstrapEpochMs(bootstrap: any): number {
117
+ const raw = readNonEmptyString(bootstrap?.startedAt) || readNonEmptyString(bootstrap?.completedAt);
118
+ if (!raw) return 0;
119
+ const parsed = Date.parse(raw);
120
+ return Number.isFinite(parsed) ? parsed : 0;
121
+ }
122
+
123
+ /**
124
+ * Directional freshness test for the bootstrap overlay: may the inline runtime state
125
+ * REPLACE the config runtime state? True only when the inline state is terminal and the
126
+ * config state is not (the synchronous terminal stamp the async persist lags), or when
127
+ * both are non-terminal but the inline epoch is strictly newer. A terminal config state is
128
+ * never overwritten by a non-terminal inline read (the stale-'running'-masks-complete
129
+ * anti-case), and equal states never trigger a rewrite.
100
130
  */
131
+ function inlineBootstrapIsFresher(inlineBootstrap: any, configBootstrap: any): boolean {
132
+ const inlineStatus = readNonEmptyString(inlineBootstrap?.status);
133
+ if (!inlineStatus) return false;
134
+ const configStatus = readNonEmptyString(configBootstrap?.status);
135
+ const inlineTerminal = BOOTSTRAP_TERMINAL_STATUSES.has(inlineStatus);
136
+ const configTerminal = !!configStatus && BOOTSTRAP_TERMINAL_STATUSES.has(configStatus);
137
+ // Config already terminal: only a DIFFERENT terminal inline state (e.g. config 'complete'
138
+ // vs a later 'failed' re-drive) may supersede it; a non-terminal inline read must never
139
+ // mask a terminal config state.
140
+ if (configTerminal) {
141
+ return inlineTerminal && inlineStatus !== configStatus
142
+ && bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
143
+ }
144
+ // Config not terminal: an inline terminal state is always fresher (opens the gate).
145
+ if (inlineTerminal) return true;
146
+ // Both non-terminal: prefer inline only when its epoch is strictly newer (a re-driven
147
+ // bootstrap the config has not observed). Equal/older ⇒ keep config, gate still defers.
148
+ return bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
149
+ }
150
+
101
151
  function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
102
152
  const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
103
153
  const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
@@ -111,10 +161,10 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
111
161
  if (!cachedId) return false;
112
162
  return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
113
163
  });
114
- // Overlay the inline cache's fresher worktreeBootstrap state onto any config node
115
- // that also exists in the inline cache. Only override when the inline entry actually
116
- // carries a bootstrap status (an incomplete inline entry never masks a genuine config
117
- // 'running'), mirroring the inline-first read the bootstrap gate does directly.
164
+ // Overlay the inline cache's fresher worktreeBootstrap state onto any config node that
165
+ // also exists in the inline cache. inlineBootstrapIsFresher() gates the overlay to the
166
+ // "terminal OR strictly newer" cases, so a stale inline 'running' can never mask a
167
+ // terminal config state and the gate's deferral is preserved for a genuine 'running'.
118
168
  let overlaidLocalNodes: any[] = localNodes;
119
169
  let overlaid = false;
120
170
  for (let i = 0; i < localNodes.length; i++) {
@@ -122,14 +172,14 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
122
172
  const localId = readMeshNodeId(localNode);
123
173
  if (!localId) continue;
124
174
  const inlineMatch = cachedNodes.find((cachedNode: any) => meshNodeIdMatches(cachedNode, localId));
125
- const inlineBootstrapStatus = readNonEmptyString(inlineMatch?.worktreeBootstrap?.status);
126
- if (!inlineMatch || !inlineBootstrapStatus) continue;
175
+ if (!inlineMatch) continue;
176
+ if (!inlineBootstrapIsFresher(inlineMatch.worktreeBootstrap, localNode.worktreeBootstrap)) continue;
127
177
  if (!overlaid) {
128
178
  overlaidLocalNodes = [...localNodes];
129
179
  overlaid = true;
130
180
  }
131
- // Keep the config node's static fields; prefer the inline cache's dynamic
132
- // bootstrap runtime state (fresher terminal stamp).
181
+ // Keep the config node's static fields; overlay only the dynamic worktreeBootstrap
182
+ // runtime substate (fresher terminal stamp / epoch) — config identity is unchanged.
133
183
  overlaidLocalNodes[i] = { ...localNode, worktreeBootstrap: inlineMatch.worktreeBootstrap };
134
184
  }
135
185
  if (!cacheOnly.length && !overlaid) return localMesh;
@@ -1091,17 +1141,36 @@ function nodeActiveLoad(meshId: string, nodeId: string): number {
1091
1141
  return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
1092
1142
  }
1093
1143
 
1144
+ /** True when any node in the mesh has an EXPLICIT capability-slot list configured
1145
+ * (policy.slots). Legacy-derived slots don't count — only an operator (or the
1146
+ * orchestrator, with approval) authoring slots signals intent to route by fitness. */
1147
+ function meshHasExplicitSlots(mesh: any): boolean {
1148
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
1149
+ return nodes.some((n: any) => normalizeNodeCapabilitySlots(n?.policy?.slots).length > 0);
1150
+ }
1151
+
1094
1152
  /**
1095
1153
  * The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
1096
1154
  * policy. Resolution order:
1097
- * 1. the stored mesh policy schedulingStrategy raw 4-union, then
1098
- * 2. 'first_eligible' (strict no-change default).
1099
- * Policy is machine-local only there is no repo-file (`.adhdev/mesh.json`)
1100
- * overlay. Only governs the final tie-break; eligibility, capacity, and priority
1101
- * gates apply identically to every strategy.
1155
+ * 1. an EXPLICITLY stored schedulingStrategy (operator picked a mode) wins, else
1156
+ * 2. 'fitness' AUTO when the mesh has any node with explicit capability slots —
1157
+ * configuring slots is itself the signal to route tasks by task→slot fitness
1158
+ * (difficulty/capability), no separate strategy toggle required, else
1159
+ * 3. 'first_eligible' (strict no-change default for slot-less meshes).
1160
+ *
1161
+ * Persistence economy drops schedulingStrategy from policy when it equals the
1162
+ * first_eligible default (repo-mesh-types normalizeMeshPolicy), so an ABSENT value
1163
+ * means "unset" — safe to auto-upgrade — while a PRESENT value means the operator
1164
+ * chose it and we never override. Policy is machine-local only; this only governs
1165
+ * the final tie-break — eligibility, capacity, and priority gates are unchanged.
1102
1166
  */
1103
1167
  function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
1104
- return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
1168
+ const raw = mesh?.policy?.schedulingStrategy;
1169
+ if (typeof raw === 'string' && raw.trim()) {
1170
+ return normalizeMeshSchedulingStrategy(raw);
1171
+ }
1172
+ // Unset → auto-fitness when slots exist, else the historical default.
1173
+ return meshHasExplicitSlots(mesh) ? 'fitness' : normalizeMeshSchedulingStrategy(undefined);
1105
1174
  }
1106
1175
 
1107
1176
  /**
@@ -1143,7 +1212,7 @@ export function __orderEligibleNodesForTests(
1143
1212
  meshId: string,
1144
1213
  strategy: RepoMeshSchedulingStrategy,
1145
1214
  nodes: RankableNode[],
1146
- opts?: { bumpCursor?: boolean },
1215
+ opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
1147
1216
  ): RankableNode[] {
1148
1217
  return orderEligibleNodes(meshId, strategy, nodes, opts);
1149
1218
  }
@@ -1199,16 +1268,107 @@ export function __buildSchedulingPoolForTests(
1199
1268
  return buildSchedulingPool(localCandidates, remoteCandidates);
1200
1269
  }
1201
1270
 
1271
+ // ─────────────────────────────────────────────────────────────────────────────
1272
+ // Node capability slots — task→node/slot fitness (ORCHESTRATION_NODE_SLOTS.md)
1273
+ //
1274
+ // A node's capability slots are the single source of truth for routing. When a
1275
+ // node has explicit `policy.slots` we use them; otherwise we derive slots from the
1276
+ // legacy providerPriority/providerRoles + the machine-global difficultyBrains so
1277
+ // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
1278
+ // specific task by how well its best slot matches the task's difficulty and
1279
+ // required tags — with graceful fallback so a task is never blocked by a missing
1280
+ // exact match.
1281
+ // ─────────────────────────────────────────────────────────────────────────────
1282
+
1283
+ /** The task shape the fitness scorer reads (a subset of MeshWorkQueueEntry). */
1284
+ interface FitnessTask {
1285
+ difficulty?: string;
1286
+ requiredTags?: string[];
1287
+ }
1288
+
1289
+ /** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
1290
+ function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
1291
+ const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
1292
+ if (explicit.length) return explicit;
1293
+ let difficultyBrains: any;
1294
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
1295
+ return deriveSlotsFromLegacy({
1296
+ providerPriority: normalizeProviderPriority(node?.policy),
1297
+ providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
1298
+ difficultyBrains,
1299
+ });
1300
+ }
1301
+
1302
+ /**
1303
+ * Score how well one slot fits a task. Higher = better. A slot whose difficulty
1304
+ * range contains the task's difficulty scores highest; a general-purpose slot
1305
+ * (no declared difficulty) is a valid fallback; a slot whose capability tags cover
1306
+ * the task's requiredTags gets a capability bonus. Never negative — the worst a
1307
+ * slot does is score 0 (still selectable as a last-resort fallback).
1308
+ */
1309
+ function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
1310
+ let score = 1; // base: any slot can run the task (fallback floor)
1311
+ const diff = isMeshTaskDifficulty(task.difficulty) ? task.difficulty as MeshTaskDifficulty : undefined;
1312
+ if (diff) {
1313
+ if (slot.difficulty?.length) {
1314
+ score += slot.difficulty.includes(diff) ? 100 : 0; // exact difficulty match dominates
1315
+ } else {
1316
+ score += 20; // general-purpose slot: decent fallback for any difficulty
1317
+ }
1318
+ }
1319
+ const req = task.requiredTags?.filter(t => !!t) ?? [];
1320
+ if (req.length) {
1321
+ const cap = new Set(slot.capability ?? []);
1322
+ const covered = req.every(t => cap.has(t));
1323
+ score += covered ? 30 : 0; // capability coverage bonus (hard filter is applied elsewhere)
1324
+ }
1325
+ return score;
1326
+ }
1327
+
1328
+ /** Best (slot, score) for a task on a node, or null when the node has no slots. */
1329
+ function bestSlotForTask(node: any, task: FitnessTask): { slot: NodeCapabilitySlot; score: number } | null {
1330
+ const slots = resolveNodeCapabilitySlots(node);
1331
+ if (!slots.length) return null;
1332
+ let best: { slot: NodeCapabilitySlot; score: number } | null = null;
1333
+ for (const slot of slots) {
1334
+ const score = scoreSlotForTask(slot, task);
1335
+ if (!best || score > best.score) best = { slot, score };
1336
+ }
1337
+ return best;
1338
+ }
1339
+
1340
+ /** Node-level fitness for a task = its best slot's score (0 when the node has no slots). */
1341
+ function nodeFitnessForTask(node: any, task: FitnessTask): number {
1342
+ return bestSlotForTask(node, task)?.score ?? 0;
1343
+ }
1344
+
1202
1345
  function orderEligibleNodes(
1203
1346
  meshId: string,
1204
1347
  strategy: RepoMeshSchedulingStrategy,
1205
1348
  nodes: RankableNode[],
1206
- opts?: { bumpCursor?: boolean },
1349
+ opts?: { bumpCursor?: boolean; task?: FitnessTask },
1207
1350
  ): RankableNode[] {
1208
1351
  if (strategy === 'first_eligible' || nodes.length <= 1) {
1209
1352
  return nodes;
1210
1353
  }
1211
1354
 
1355
+ // Fitness strategy: rank by task→slot fit first (when a task is in scope —
1356
+ // auto-launch drains per-task), then fall through to priority/load/rotation for
1357
+ // ties. Without a task (idle-session drain ranks task-independently) fitness is
1358
+ // inert and this behaves like least_loaded.
1359
+ if (strategy === 'fitness' && opts?.task) {
1360
+ const task = opts.task;
1361
+ return [...nodes].sort((a, b) => {
1362
+ const fitDelta = nodeFitnessForTask(b.node, task) - nodeFitnessForTask(a.node, task);
1363
+ if (fitDelta !== 0) return fitDelta; // higher fitness first
1364
+ const prioDelta = resolveNodeSchedulingPriority(b.node?.policy) - resolveNodeSchedulingPriority(a.node?.policy);
1365
+ if (prioDelta !== 0) return prioDelta;
1366
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1367
+ if (loadDelta !== 0) return loadDelta;
1368
+ return a.index - b.index;
1369
+ });
1370
+ }
1371
+
1212
1372
  const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
1213
1373
 
1214
1374
  // Round-robin rotation offset: rotate the deterministic input order by a
@@ -1492,19 +1652,29 @@ async function resolveUsableProvider(
1492
1652
  nodeId: string,
1493
1653
  node: any,
1494
1654
  requiredTags?: string[],
1495
- ): Promise<{ providerType?: string; reason?: string }> {
1496
- const providerPriority = normalizeProviderPriority(node?.policy);
1497
- if (!providerPriority.length) return { reason: 'missing_provider_priority' };
1655
+ task?: FitnessTask,
1656
+ ): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; reason?: string }> {
1498
1657
  const providerLoader = components.providerLoader;
1499
1658
  if (!providerLoader) return { reason: 'provider_loader_unavailable' };
1500
1659
 
1660
+ // Slot-based order (ORCHESTRATION_NODE_SLOTS.md): rank the node's capability
1661
+ // slots by task→slot fitness (difficulty/requiredTags) so the best-fit slot's
1662
+ // provider is tried first, and its model/thinkingLevel ride along. Falls back
1663
+ // to the legacy providerPriority-derived slots when no explicit slots exist.
1664
+ const slots = resolveNodeCapabilitySlots(node);
1665
+ if (!slots.length) return { reason: 'missing_provider_priority' };
1666
+ const orderedSlots = task
1667
+ ? [...slots].sort((a, b) => scoreSlotForTask(b, task) - scoreSlotForTask(a, task))
1668
+ : slots;
1669
+
1501
1670
  const failed: string[] = [];
1502
- for (const requestedType of providerPriority) {
1671
+ for (const slot of orderedSlots) {
1672
+ const requestedType = slot.provider;
1503
1673
  const normalizedType = typeof providerLoader.resolveAlias === 'function'
1504
1674
  ? providerLoader.resolveAlias(requestedType)
1505
1675
  : requestedType;
1506
1676
  // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
1507
- // means only hermes-cli qualifies, not any other type in providerPriority).
1677
+ // means only hermes-cli qualifies, not any other slot's provider).
1508
1678
  if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
1509
1679
  failed.push(`${requestedType}: required_tags_mismatch`);
1510
1680
  continue;
@@ -1528,7 +1698,13 @@ async function resolveUsableProvider(
1528
1698
  }], false);
1529
1699
  }
1530
1700
  (components as any).onStatusChange?.();
1531
- if (detected) return { providerType: normalizedType };
1701
+ if (detected) {
1702
+ return {
1703
+ providerType: normalizedType,
1704
+ ...(slot.model ? { model: slot.model } : {}),
1705
+ ...(slot.thinkingLevel ? { thinkingLevel: slot.thinkingLevel } : {}),
1706
+ };
1707
+ }
1532
1708
  failed.push(`${requestedType}: not detected`);
1533
1709
  }
1534
1710
  return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
@@ -1800,7 +1976,9 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1800
1976
  candidateNodes
1801
1977
  .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1802
1978
  .filter((c: RankableNode) => c.nodeId),
1803
- { bumpCursor: true },
1979
+ // Auto-launch drains one task at a time, so the task IS in scope here —
1980
+ // pass it through for the 'fitness' strategy's task→slot ranking.
1981
+ { bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags } },
1804
1982
  ).map((c: RankableNode) => c.node);
1805
1983
 
1806
1984
  for (const node of orderedCandidateNodes) {
@@ -1864,11 +2042,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1864
2042
 
1865
2043
  autoLaunchInProgress.add(launchKey);
1866
2044
  try {
1867
- const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags);
2045
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags });
1868
2046
  if (!resolved.providerType) {
1869
2047
  markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
1870
2048
  continue;
1871
2049
  }
2050
+ // Slot-derived model/thinking: an explicit task.model/thinkingLevel
2051
+ // (resolved from the enqueue-time brain) still wins; the matched
2052
+ // slot fills only what the task left blank (ORCHESTRATION_NODE_SLOTS.md).
2053
+ const effectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2054
+ const effectiveThinkingLevel = (typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim()) ? task.thinkingLevel.trim() : resolved.thinkingLevel;
1872
2055
 
1873
2056
  // Don't spawn a session for a (node, provider) already at its declared
1874
2057
  // maxParallel cap — it would launch only to fail the claim. The claim
@@ -1917,9 +2100,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1917
2100
  settings: remoteSettings,
1918
2101
  // MAGI-KIND-PANEL model axis: forward the task's model override so the
1919
2102
  // remote worker session launches with it (initialModel). Best-effort.
1920
- ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
1921
- // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
1922
- ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
2103
+ // Slot-aware: task override wins, else the matched slot's model.
2104
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2105
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2106
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
1923
2107
  });
1924
2108
  } catch (e: any) {
1925
2109
  markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
@@ -1949,11 +2133,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1949
2133
  cliType: resolved.providerType,
1950
2134
  dir: node.workspace,
1951
2135
  settings: launchSettings,
1952
- // MAGI-KIND-PANEL model axis: local launch forwards the task's model
1953
- // override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
1954
- ...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
1955
- // BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
1956
- ...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
2136
+ // MAGI-KIND-PANEL model axis: local launch forwards the effective model
2137
+ // (task override, else matched slot) as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
2138
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2139
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2140
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
1957
2141
  });
1958
2142
  if (!launchResult?.success) {
1959
2143
  const reason = launchResult?.error || 'launch_cli_failed';
@@ -2161,7 +2345,10 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
2161
2345
  const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
2162
2346
  const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
2163
2347
  if (aPrio !== bPrio) return bPrio - aPrio;
2164
- if (strategy === 'least_loaded' || strategy === 'round_robin') {
2348
+ // The idle-session drain ranks task-independently (a session pulls
2349
+ // whatever task matches), so 'fitness' here reduces to load-aware
2350
+ // ordering — the same tiebreak as least_loaded/round_robin.
2351
+ if (strategy === 'least_loaded' || strategy === 'round_robin' || strategy === 'fitness') {
2165
2352
  const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
2166
2353
  if (loadDelta !== 0) return loadDelta;
2167
2354
  }
@@ -591,6 +591,15 @@ export interface MeshWorkQueueEntry {
591
591
  * setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
592
592
  */
593
593
  thinkingLevel?: string;
594
+ /**
595
+ * SLOT-ROUTING (ORCHESTRATION_NODE_SLOTS.md): the coordinator's difficulty
596
+ * classification for this task ('easy'|'medium'|'difficult'|'freeform'),
597
+ * PERSISTED on the entry so the scheduler can match it against node capability
598
+ * slots at assignment time. Previously an enqueue-only option consumed to
599
+ * resolve model/thinkingLevel and then discarded; keeping it lets task→node
600
+ * fitness matching run. Absent on tasks enqueued without a difficulty.
601
+ */
602
+ difficulty?: string;
594
603
  /**
595
604
  * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
596
605
  * Only set by the system on dependency failure under the 'block' policy;
@@ -904,6 +913,10 @@ export function enqueueTask(
904
913
  // an unconfigured preset just leaves the explicit values (or none) in place.
905
914
  let effectiveModel = typeof opts?.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined;
906
915
  let effectiveThinkingLevel = typeof opts?.thinkingLevel === 'string' && opts.thinkingLevel.trim() ? opts.thinkingLevel.trim() : undefined;
916
+ // SLOT-ROUTING: persist the difficulty class on the entry so the scheduler can
917
+ // match it against node capability slots at assignment time (not just resolve
918
+ // model/thinking here). Absent/invalid → undefined (task carries no difficulty).
919
+ const taskDifficulty = isMeshTaskDifficulty(opts?.difficulty) ? (opts!.difficulty as MeshTaskDifficulty) : undefined;
907
920
  if (isMeshTaskDifficulty(opts?.difficulty)) {
908
921
  try {
909
922
  const preset = getDifficultyBrains()[opts!.difficulty as MeshTaskDifficulty];
@@ -951,6 +964,7 @@ export function enqueueTask(
951
964
  ...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
952
965
  ...(effectiveModel ? { model: effectiveModel } : {}),
953
966
  ...(effectiveThinkingLevel ? { thinkingLevel: effectiveThinkingLevel } : {}),
967
+ ...(taskDifficulty ? { difficulty: taskDifficulty } : {}),
954
968
  ...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
955
969
  ? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
956
970
  : {}),
@@ -14,7 +14,7 @@
14
14
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
15
15
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
16
16
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
17
- import type { MagiKindPanelMap, DifficultyBrainMap } from '@adhdev/mesh-shared';
17
+ import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
18
18
 
19
19
  // ─── Core Mesh Types ────────────────────────────
20
20
 
@@ -139,13 +139,18 @@ export type RepoMeshSchedulingStrategy =
139
139
  | 'first_eligible'
140
140
  | 'least_loaded'
141
141
  | 'round_robin'
142
- | 'priority_only';
142
+ | 'priority_only'
143
+ // ORCHESTRATION_NODE_SLOTS.md: rank nodes by task→capability-slot fitness
144
+ // (task difficulty/requiredTags vs the node's slots), then priority/load/order.
145
+ // Falls back to load ordering when no task is in scope (idle-session drain).
146
+ | 'fitness';
143
147
 
144
148
  export const MESH_SCHEDULING_STRATEGIES: RepoMeshSchedulingStrategy[] = [
145
149
  'first_eligible',
146
150
  'least_loaded',
147
151
  'round_robin',
148
152
  'priority_only',
153
+ 'fitness',
149
154
  ];
150
155
 
151
156
  export const DEFAULT_MESH_SCHEDULING_STRATEGY: RepoMeshSchedulingStrategy = 'first_eligible';
@@ -403,8 +408,22 @@ export interface RepoMeshNodePolicy {
403
408
  * enforced as an additional, stricter-wins constraint on top of the global
404
409
  * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
405
410
  * before (global caps only). Routing is governed solely by required_tags.
411
+ *
412
+ * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
413
+ * when `slots` is absent, providerRoles + providerPriority + the machine-global
414
+ * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
406
415
  */
407
416
  providerRoles?: RepoMeshProviderRole[];
417
+ /**
418
+ * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
419
+ * AI tools" profile that is the single source of truth for task routing, MAGI
420
+ * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
421
+ * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
422
+ * Order = preference. When absent, the scheduler derives slots from the legacy
423
+ * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
424
+ * existing nodes keep working without reconfiguration.
425
+ */
426
+ slots?: NodeCapabilitySlot[];
408
427
  /**
409
428
  * Per-node override for RepoMeshPolicy.delegatedWorkerAutoApprove. When set, takes
410
429
  * precedence over the mesh-level policy for worker sessions launched onto this node.
@@ -440,7 +459,11 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
440
459
  allowAutoPublishSubmoduleMainCommits: false,
441
460
  requireApprovalForDestructiveGit: true,
442
461
  dirtyWorkspaceBehavior: 'warn',
443
- maxParallelTasks: 2,
462
+ // Mesh-wide task cap is effectively unlimited by default: the real concurrency
463
+ // limits live per node / per capability slot (ORCHESTRATION_NODE_SLOTS.md), so a
464
+ // global ceiling is rarely meaningful. The UI hides this control; set it via the
465
+ // API only to impose a deliberate mesh-wide cap.
466
+ maxParallelTasks: 200,
444
467
  // Coordinator-spawned worker sessions default to hidden so the dashboard is not
445
468
  // flooded with mesh noise tabs/notifications. Users can still surface or unmute
446
469
  // any specific session manually; that override is preserved per-device.
@@ -1143,6 +1166,8 @@ export interface RepoMeshNodeStatus {
1143
1166
  activeSessions: string[];
1144
1167
  activeSessionDetails?: RepoMeshSessionStatus[];
1145
1168
  providerPriority?: string[];
1169
+ /** Explicitly-configured node capability slots (ORCHESTRATION_NODE_SLOTS.md). */
1170
+ slots?: NodeCapabilitySlot[];
1146
1171
  launchReady?: boolean;
1147
1172
  /** True when the node is clean, ahead=0, behind>0, and safe for fast-forward consideration. */
1148
1173
  autoFastForwardEligible?: boolean;
@@ -416,6 +416,13 @@ export interface SessionEntry {
416
416
  completionMarker?: string;
417
417
  seenCompletionMarker?: string;
418
418
  surfaceHidden?: boolean;
419
+ /**
420
+ * User (or coordinator-policy) muted: suppress attention side-effects
421
+ * (notifications, toasts, completion audio) for this session WITHOUT removing
422
+ * it from the inbox list. Distinct from surfaceHidden (which collapses it from
423
+ * the list). Daemon-owned, in-memory; rides the status snapshot.
424
+ */
425
+ muted?: boolean;
419
426
  settings?: Record<string, any>;
420
427
  /**
421
428
  * True owning-daemon id for a session a coordinator synthesises into its own
@@ -488,6 +495,7 @@ export interface CompactSessionEntry {
488
495
  completionMarker?: string;
489
496
  seenCompletionMarker?: string;
490
497
  surfaceHidden?: boolean;
498
+ muted?: boolean;
491
499
  controlValues?: Record<string, string | number | boolean>;
492
500
  providerControls?: ProviderControlSchema[];
493
501
  summaryMetadata?: ProviderSummaryMetadata;
@@ -34,6 +34,47 @@ import {
34
34
  } from '../providers/open-panel-support.js';
35
35
  import { TEXT_ONLY_MESSAGE_INPUT_SUPPORT } from '../providers/provider-input-support.js';
36
36
 
37
+ /**
38
+ * A coordinator-spawned worker session that mesh policy launched hidden. This is
39
+ * the daemon-side equivalent of the web `shouldAutoHideMeshConversation` predicate:
40
+ * these sessions should default to muted+hidden in the user dashboard (the user
41
+ * interacts through the ONE coordinator session, not each worker), while the
42
+ * coordinator↔worker mesh data/completion path (mesh-event-forwarding) is
43
+ * unaffected.
44
+ */
45
+ function isCoordinatorSpawnedHiddenWorker(settings: Record<string, any> | undefined): boolean {
46
+ if (!settings) return false;
47
+ return settings.launchedByCoordinator === true
48
+ && typeof settings.meshNodeFor === 'string'
49
+ && settings.meshNodeFor.trim().length > 0
50
+ && settings.spawnedSessionVisibility === 'hidden';
51
+ }
52
+
53
+ /**
54
+ * A session is surface-hidden (collapsed from the user's inbox/notifications) when
55
+ * mesh policy spawned it hidden, OR when a coordinator-spawned worker defaults
56
+ * hidden, OR when the user manually hid it (userHidden). userHidden === false is an
57
+ * explicit un-hide that overrides the policy/worker default until daemon restart.
58
+ */
59
+ function resolveSurfaceHidden(settings: Record<string, any> | undefined): boolean {
60
+ if (!settings) return false;
61
+ if (settings.userHidden === true) return true;
62
+ if (settings.userHidden === false) return false;
63
+ return settings.spawnedSessionVisibility === 'hidden' || isCoordinatorSpawnedHiddenWorker(settings);
64
+ }
65
+
66
+ /**
67
+ * A session is muted (attention side-effects suppressed, but still shown in the
68
+ * list) when the user muted it, OR a coordinator-spawned worker defaults muted.
69
+ * userMuted === false is an explicit un-mute overriding the worker default.
70
+ */
71
+ function resolveMuted(settings: Record<string, any> | undefined): boolean {
72
+ if (!settings) return false;
73
+ if (settings.userMuted === true) return true;
74
+ if (settings.userMuted === false) return false;
75
+ return isCoordinatorSpawnedHiddenWorker(settings);
76
+ }
77
+
37
78
  export type SessionEntryProfile = 'full' | 'live' | 'metadata';
38
79
 
39
80
  export interface SessionEntryBuildOptions {
@@ -342,7 +383,8 @@ function buildCliSession(state: CliProviderState, options: SessionEntryBuildOpti
342
383
  settings: state.settings,
343
384
  ...(coordinator && { coordinator }),
344
385
  ...(meshQueueStats && { meshQueueStats }),
345
- ...(state.settings?.spawnedSessionVisibility === 'hidden' && { surfaceHidden: true }),
386
+ ...(resolveSurfaceHidden(state.settings) && { surfaceHidden: true }),
387
+ ...(resolveMuted(state.settings) && { muted: true }),
346
388
  };
347
389
  }
348
390
 
@@ -384,7 +426,8 @@ function buildAcpSession(state: AcpProviderState, options: SessionEntryBuildOpti
384
426
  settings: state.settings,
385
427
  ...(coordinator && { coordinator }),
386
428
  ...(meshQueueStats && { meshQueueStats }),
387
- ...(state.settings?.spawnedSessionVisibility === 'hidden' && { surfaceHidden: true }),
429
+ ...(resolveSurfaceHidden(state.settings) && { surfaceHidden: true }),
430
+ ...(resolveMuted(state.settings) && { muted: true }),
388
431
  };
389
432
  }
390
433
 
@@ -130,7 +130,7 @@ function buildDetectedIdeInfos(
130
130
  }));
131
131
  }
132
132
 
133
- function buildAvailableProviders(
133
+ export function buildAvailableProviders(
134
134
  providerLoader: StatusSnapshotOptions['providerLoader'],
135
135
  ): AvailableProviderInfo[] {
136
136
  const providers: Array<{