@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.
- package/dist/cli-adapters/provider-cli-adapter.d.ts +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +311 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +310 -34
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +4 -0
- package/dist/mesh/mesh-work-queue.d.ts +9 -0
- package/dist/repo-mesh-types.d.ts +18 -2
- package/dist/shared-types.d.ts +8 -0
- package/dist/status/snapshot.d.ts +1 -0
- package/package.json +3 -3
- package/src/cli-adapters/provider-cli-adapter.ts +70 -1
- package/src/commands/handler.ts +21 -1
- package/src/commands/high-family/mesh-coordinator-launch.ts +17 -1
- package/src/commands/high-family/mesh-status.ts +8 -0
- package/src/commands/med-family/cli-agent.ts +29 -0
- package/src/index.ts +1 -1
- package/src/mesh/coordinator-prompt.ts +4 -1
- package/src/mesh/mesh-queue-assignment.ts +227 -40
- package/src/mesh/mesh-work-queue.ts +14 -0
- package/src/repo-mesh-types.ts +28 -3
- package/src/shared-types.ts +8 -0
- package/src/status/builders.ts +45 -2
- package/src/status/snapshot.ts +1 -1
|
@@ -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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
-
|
|
126
|
-
if (!inlineMatch
|
|
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;
|
|
132
|
-
//
|
|
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.
|
|
1098
|
-
* 2. '
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
|
1953
|
-
// override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
|
|
1954
|
-
...(
|
|
1955
|
-
// BRAIN-ROUTING thinking axis: forward the
|
|
1956
|
-
...(
|
|
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
|
-
|
|
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
|
: {}),
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/shared-types.ts
CHANGED
|
@@ -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;
|
package/src/status/builders.ts
CHANGED
|
@@ -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
|
|
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
|
|
429
|
+
...(resolveSurfaceHidden(state.settings) && { surfaceHidden: true }),
|
|
430
|
+
...(resolveMuted(state.settings) && { muted: true }),
|
|
388
431
|
};
|
|
389
432
|
}
|
|
390
433
|
|
package/src/status/snapshot.ts
CHANGED
|
@@ -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<{
|