@adhdev/daemon-core 0.9.82-rc.485 → 0.9.82-rc.487
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/git/git-status.d.ts +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +343 -55
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +342 -55
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-queue-assignment.d.ts +4 -0
- package/dist/mesh/mesh-refine-gates.d.ts +29 -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/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/commands/router-refine.ts +27 -2
- package/src/git/git-status.ts +84 -29
- package/src/index.ts +1 -1
- package/src/mesh/coordinator-prompt.ts +4 -1
- package/src/mesh/mesh-queue-assignment.ts +164 -27
- package/src/mesh/mesh-refine-gates.ts +113 -7
- 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';
|
|
@@ -1141,17 +1141,36 @@ function nodeActiveLoad(meshId: string, nodeId: string): number {
|
|
|
1141
1141
|
return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
|
|
1142
1142
|
}
|
|
1143
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
|
+
|
|
1144
1152
|
/**
|
|
1145
1153
|
* The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
|
|
1146
1154
|
* policy. Resolution order:
|
|
1147
|
-
* 1.
|
|
1148
|
-
* 2. '
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
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.
|
|
1152
1166
|
*/
|
|
1153
1167
|
function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
|
|
1154
|
-
|
|
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);
|
|
1155
1174
|
}
|
|
1156
1175
|
|
|
1157
1176
|
/**
|
|
@@ -1193,7 +1212,7 @@ export function __orderEligibleNodesForTests(
|
|
|
1193
1212
|
meshId: string,
|
|
1194
1213
|
strategy: RepoMeshSchedulingStrategy,
|
|
1195
1214
|
nodes: RankableNode[],
|
|
1196
|
-
opts?: { bumpCursor?: boolean },
|
|
1215
|
+
opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
|
|
1197
1216
|
): RankableNode[] {
|
|
1198
1217
|
return orderEligibleNodes(meshId, strategy, nodes, opts);
|
|
1199
1218
|
}
|
|
@@ -1249,16 +1268,107 @@ export function __buildSchedulingPoolForTests(
|
|
|
1249
1268
|
return buildSchedulingPool(localCandidates, remoteCandidates);
|
|
1250
1269
|
}
|
|
1251
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
|
+
|
|
1252
1345
|
function orderEligibleNodes(
|
|
1253
1346
|
meshId: string,
|
|
1254
1347
|
strategy: RepoMeshSchedulingStrategy,
|
|
1255
1348
|
nodes: RankableNode[],
|
|
1256
|
-
opts?: { bumpCursor?: boolean },
|
|
1349
|
+
opts?: { bumpCursor?: boolean; task?: FitnessTask },
|
|
1257
1350
|
): RankableNode[] {
|
|
1258
1351
|
if (strategy === 'first_eligible' || nodes.length <= 1) {
|
|
1259
1352
|
return nodes;
|
|
1260
1353
|
}
|
|
1261
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
|
+
|
|
1262
1372
|
const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
|
|
1263
1373
|
|
|
1264
1374
|
// Round-robin rotation offset: rotate the deterministic input order by a
|
|
@@ -1542,19 +1652,29 @@ async function resolveUsableProvider(
|
|
|
1542
1652
|
nodeId: string,
|
|
1543
1653
|
node: any,
|
|
1544
1654
|
requiredTags?: string[],
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
if (!providerPriority.length) return { reason: 'missing_provider_priority' };
|
|
1655
|
+
task?: FitnessTask,
|
|
1656
|
+
): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; reason?: string }> {
|
|
1548
1657
|
const providerLoader = components.providerLoader;
|
|
1549
1658
|
if (!providerLoader) return { reason: 'provider_loader_unavailable' };
|
|
1550
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
|
+
|
|
1551
1670
|
const failed: string[] = [];
|
|
1552
|
-
for (const
|
|
1671
|
+
for (const slot of orderedSlots) {
|
|
1672
|
+
const requestedType = slot.provider;
|
|
1553
1673
|
const normalizedType = typeof providerLoader.resolveAlias === 'function'
|
|
1554
1674
|
? providerLoader.resolveAlias(requestedType)
|
|
1555
1675
|
: requestedType;
|
|
1556
1676
|
// Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
|
|
1557
|
-
// means only hermes-cli qualifies, not any other
|
|
1677
|
+
// means only hermes-cli qualifies, not any other slot's provider).
|
|
1558
1678
|
if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
|
|
1559
1679
|
failed.push(`${requestedType}: required_tags_mismatch`);
|
|
1560
1680
|
continue;
|
|
@@ -1578,7 +1698,13 @@ async function resolveUsableProvider(
|
|
|
1578
1698
|
}], false);
|
|
1579
1699
|
}
|
|
1580
1700
|
(components as any).onStatusChange?.();
|
|
1581
|
-
if (detected)
|
|
1701
|
+
if (detected) {
|
|
1702
|
+
return {
|
|
1703
|
+
providerType: normalizedType,
|
|
1704
|
+
...(slot.model ? { model: slot.model } : {}),
|
|
1705
|
+
...(slot.thinkingLevel ? { thinkingLevel: slot.thinkingLevel } : {}),
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1582
1708
|
failed.push(`${requestedType}: not detected`);
|
|
1583
1709
|
}
|
|
1584
1710
|
return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
|
|
@@ -1850,7 +1976,9 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1850
1976
|
candidateNodes
|
|
1851
1977
|
.map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
|
|
1852
1978
|
.filter((c: RankableNode) => c.nodeId),
|
|
1853
|
-
|
|
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 } },
|
|
1854
1982
|
).map((c: RankableNode) => c.node);
|
|
1855
1983
|
|
|
1856
1984
|
for (const node of orderedCandidateNodes) {
|
|
@@ -1914,11 +2042,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1914
2042
|
|
|
1915
2043
|
autoLaunchInProgress.add(launchKey);
|
|
1916
2044
|
try {
|
|
1917
|
-
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 });
|
|
1918
2046
|
if (!resolved.providerType) {
|
|
1919
2047
|
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
|
|
1920
2048
|
continue;
|
|
1921
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;
|
|
1922
2055
|
|
|
1923
2056
|
// Don't spawn a session for a (node, provider) already at its declared
|
|
1924
2057
|
// maxParallel cap — it would launch only to fail the claim. The claim
|
|
@@ -1967,9 +2100,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1967
2100
|
settings: remoteSettings,
|
|
1968
2101
|
// MAGI-KIND-PANEL model axis: forward the task's model override so the
|
|
1969
2102
|
// remote worker session launches with it (initialModel). Best-effort.
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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 } : {}),
|
|
1973
2107
|
});
|
|
1974
2108
|
} catch (e: any) {
|
|
1975
2109
|
markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
|
|
@@ -1999,11 +2133,11 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1999
2133
|
cliType: resolved.providerType,
|
|
2000
2134
|
dir: node.workspace,
|
|
2001
2135
|
settings: launchSettings,
|
|
2002
|
-
// MAGI-KIND-PANEL model axis: local launch forwards the
|
|
2003
|
-
// override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
|
|
2004
|
-
...(
|
|
2005
|
-
// BRAIN-ROUTING thinking axis: forward the
|
|
2006
|
-
...(
|
|
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 } : {}),
|
|
2007
2141
|
});
|
|
2008
2142
|
if (!launchResult?.success) {
|
|
2009
2143
|
const reason = launchResult?.error || 'launch_cli_failed';
|
|
@@ -2211,7 +2345,10 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
|
|
|
2211
2345
|
const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
|
|
2212
2346
|
const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
|
|
2213
2347
|
if (aPrio !== bPrio) return bPrio - aPrio;
|
|
2214
|
-
|
|
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') {
|
|
2215
2352
|
const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
|
|
2216
2353
|
if (loadDelta !== 0) return loadDelta;
|
|
2217
2354
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { getGitRepoStatus } from '../git/git-status.js';
|
|
16
|
+
import type { ChangedPackageClassification } from '../git/git-status.js';
|
|
16
17
|
import * as yaml from 'js-yaml';
|
|
17
18
|
import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
|
|
18
19
|
import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
|
|
@@ -74,6 +75,19 @@ type MeshRefineValidationSummary = {
|
|
|
74
75
|
};
|
|
75
76
|
/** M2-2: deprecation notices from the refine config (e.g. bootstrapCommands). */
|
|
76
77
|
deprecationWarnings?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Coarse daemon-vs-web change-impact used to scope the validation command set.
|
|
80
|
+
* When `isDaemonAffecting === false`, daemon-scoped commands are recorded in
|
|
81
|
+
* `commandsRun` with `skipped: true, skipReason: 'unaffected_daemon_scope'`
|
|
82
|
+
* rather than executed; web + typecheck commands always run. Absent when no
|
|
83
|
+
* change-impact was threaded in (legacy: full command set runs).
|
|
84
|
+
*/
|
|
85
|
+
changeImpact?: {
|
|
86
|
+
isDaemonAffecting: boolean;
|
|
87
|
+
affectedPackages: string[];
|
|
88
|
+
/** displayCommands skipped because the daemon scope is unaffected. */
|
|
89
|
+
skippedDaemonCommands?: string[];
|
|
90
|
+
};
|
|
77
91
|
};
|
|
78
92
|
|
|
79
93
|
type MeshRefineStageStatus = 'passed' | 'failed' | 'skipped';
|
|
@@ -343,6 +357,13 @@ export interface RefineContext {
|
|
|
343
357
|
baseBranch: string;
|
|
344
358
|
baseHead: string;
|
|
345
359
|
branchHead: string;
|
|
360
|
+
/**
|
|
361
|
+
* Coarse daemon-vs-web change-impact for baseHead..branchHead, resolved in the
|
|
362
|
+
* resolve_refs stage and threaded into the validation gate to scope its command
|
|
363
|
+
* set. `undefined` means "could not classify" → the gate fails open and runs ALL
|
|
364
|
+
* commands (never skip on uncertainty).
|
|
365
|
+
*/
|
|
366
|
+
changeImpact?: ChangedPackageClassification;
|
|
346
367
|
validationSummary: Awaited<ReturnType<typeof runMeshRefineValidationGate>>;
|
|
347
368
|
patchEquivalence: Awaited<ReturnType<typeof runMeshRefinePatchEquivalenceGate>>;
|
|
348
369
|
submoduleReachability: Awaited<ReturnType<typeof runMeshRefineSubmoduleReachabilityGate>>;
|
|
@@ -1477,6 +1498,14 @@ export async function runMeshRefineValidationGate(
|
|
|
1477
1498
|
persistedBootstrapState?: WorktreeBootstrapState | null;
|
|
1478
1499
|
/** M2-2: called after an inherit-mode bootstrap run so the caller can persist the new state. */
|
|
1479
1500
|
onBootstrapStateChange?: (state: WorktreeBootstrapState) => void;
|
|
1501
|
+
/**
|
|
1502
|
+
* Coarse daemon-vs-web change-impact for the branch (resolve_refs computes it
|
|
1503
|
+
* over baseHead..branchHead). When provided and `isDaemonAffecting === false`,
|
|
1504
|
+
* daemon-scoped validation commands are skipped (web + typecheck still run).
|
|
1505
|
+
* When omitted or `isDaemonAffecting === true`, the full command set runs —
|
|
1506
|
+
* fail-open to full validation on any uncertainty.
|
|
1507
|
+
*/
|
|
1508
|
+
changeImpact?: ChangedPackageClassification;
|
|
1480
1509
|
},
|
|
1481
1510
|
): Promise<MeshRefineValidationSummary> {
|
|
1482
1511
|
const { execFile } = await import('node:child_process');
|
|
@@ -1574,6 +1603,63 @@ export async function runMeshRefineValidationGate(
|
|
|
1574
1603
|
return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock']
|
|
1575
1604
|
.some(lock => fs.existsSync(pathJoin(cwd, lock)));
|
|
1576
1605
|
};
|
|
1606
|
+
// A validation command needs installed node_modules to run. Only these can hit
|
|
1607
|
+
// the missing-deps hard-block; non-package-manager commands (e.g. a plain
|
|
1608
|
+
// `node scripts/check-vendor-drift.mjs`) need no deps and must never be aborted
|
|
1609
|
+
// by a preceding command's missing-deps.
|
|
1610
|
+
const needsNodeModules = (candidate: MeshRefineValidationCommand, cwd: string): boolean =>
|
|
1611
|
+
isPackageManagerValidation(candidate) && dependenciesLikelyMissing(cwd);
|
|
1612
|
+
|
|
1613
|
+
// (a) Coarse change-impact scoping. When the branch is web-only
|
|
1614
|
+
// (changeImpact.isDaemonAffecting === false), daemon-scoped validation commands
|
|
1615
|
+
// are pointless — and often un-runnable in a web-only worktree that never
|
|
1616
|
+
// bootstrapped daemon deps. Identify daemon-scoped commands ONLY by the coarse
|
|
1617
|
+
// daemon-vs-web bucket: a command whose script/args reference a daemon package
|
|
1618
|
+
// (daemon-core / daemon-cloud) or the vendor-drift check. web-side commands
|
|
1619
|
+
// (test:web-core / test:web-cloud) and `typecheck` ALWAYS run — the daemon/web
|
|
1620
|
+
// boundary is the human-curated safe line; we deliberately do NOT do fine
|
|
1621
|
+
// per-package skipping (web-cloud consumes web-core, so it must still run).
|
|
1622
|
+
const isDaemonScopedCommand = (candidate: MeshRefineValidationCommand): boolean => {
|
|
1623
|
+
const haystack = [candidate.command, ...(candidate.args || []), candidate.displayCommand || '']
|
|
1624
|
+
.join(' ')
|
|
1625
|
+
.toLowerCase();
|
|
1626
|
+
// Never treat a typecheck or an explicit web-side command as daemon-scoped.
|
|
1627
|
+
if (candidate.category === 'typecheck') return false;
|
|
1628
|
+
if (/\btypecheck\b/.test(haystack)) return false;
|
|
1629
|
+
if (/\bweb-core\b|\bweb-cloud\b|\bweb-standalone\b|\btest:web\b/.test(haystack)) return false;
|
|
1630
|
+
// Daemon-scoped signals: a daemon package name, a daemon test script, or the
|
|
1631
|
+
// vendor-drift check (which validates the daemon vendor bundle).
|
|
1632
|
+
return /\bdaemon-core\b|\bdaemon-cloud\b|\btest:daemon\b|check-vendor-drift/.test(haystack);
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
const scopeUnaffectedDaemon = opts?.changeImpact?.isDaemonAffecting === false;
|
|
1636
|
+
const skippedDaemonCommands: string[] = [];
|
|
1637
|
+
const commandsToRun: MeshRefineValidationCommand[] = [];
|
|
1638
|
+
for (const candidate of selection.commands) {
|
|
1639
|
+
if (scopeUnaffectedDaemon && isDaemonScopedCommand(candidate)) {
|
|
1640
|
+
skippedDaemonCommands.push(candidate.displayCommand);
|
|
1641
|
+
// Record the skip so it's visible in the summary, never silently dropped.
|
|
1642
|
+
summary.commandsRun.push({
|
|
1643
|
+
command: candidate.command,
|
|
1644
|
+
args: candidate.args,
|
|
1645
|
+
displayCommand: candidate.displayCommand,
|
|
1646
|
+
category: candidate.category,
|
|
1647
|
+
source: candidate.source,
|
|
1648
|
+
passed: true,
|
|
1649
|
+
skipped: true,
|
|
1650
|
+
skipReason: 'unaffected_daemon_scope',
|
|
1651
|
+
});
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
commandsToRun.push(candidate);
|
|
1655
|
+
}
|
|
1656
|
+
if (opts?.changeImpact) {
|
|
1657
|
+
summary.changeImpact = {
|
|
1658
|
+
isDaemonAffecting: opts.changeImpact.isDaemonAffecting,
|
|
1659
|
+
affectedPackages: opts.changeImpact.affectedPackages,
|
|
1660
|
+
...(skippedDaemonCommands.length ? { skippedDaemonCommands } : {}),
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1577
1663
|
|
|
1578
1664
|
if (runLegacyBootstrapCommands) {
|
|
1579
1665
|
summary.bootstrap = { stage: 'legacy' };
|
|
@@ -1619,23 +1705,31 @@ export async function runMeshRefineValidationGate(
|
|
|
1619
1705
|
}
|
|
1620
1706
|
}
|
|
1621
1707
|
|
|
1622
|
-
|
|
1708
|
+
// (b) Track a genuine missing-deps block for an AFFECTED command. Instead of
|
|
1709
|
+
// aborting the whole gate at the first missing-deps hit (which also killed
|
|
1710
|
+
// trailing no-dep commands like check-vendor-drift.mjs), we mark the blocked
|
|
1711
|
+
// command and CONTINUE evaluating the rest: commands whose deps are present, or
|
|
1712
|
+
// which need no deps at all, still run. missing_dependencies only becomes the
|
|
1713
|
+
// gate failure if at least one command that truly needed deps could not run.
|
|
1714
|
+
let missingDepsBlocked = false;
|
|
1715
|
+
for (const candidate of commandsToRun) {
|
|
1623
1716
|
const startedAt = Date.now();
|
|
1624
1717
|
const cwd = candidate.cwd ? pathResolve(workspace, candidate.cwd) : workspace;
|
|
1625
1718
|
const timeout = candidate.timeoutMs || REFINE_VALIDATION_TIMEOUT_MS;
|
|
1626
1719
|
const bootstrapProvidedDependencies = summary.bootstrap?.stage === 'cached' || summary.bootstrap?.stage === 'ran' || summary.bootstrap?.stage === 'legacy';
|
|
1627
|
-
if (!bootstrapProvidedDependencies &&
|
|
1720
|
+
if (!bootstrapProvidedDependencies && needsNodeModules(candidate, cwd)) {
|
|
1721
|
+
// This command genuinely needs node_modules that are absent. Mark it
|
|
1722
|
+
// blocked, but do NOT abort — a following no-dep command (or one in a
|
|
1723
|
+
// different cwd that DOES have deps) must still get its chance to run.
|
|
1628
1724
|
summary.commandsRun.push(commandRecord(candidate, cwd, startedAt, {
|
|
1629
|
-
stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands in repo mesh/refine config if Refinery should install/bootstrap before validation.',
|
|
1725
|
+
stderr: 'Dependencies appear to be missing: package.json and a lockfile are present, but node_modules is absent. Configure validation.bootstrapCommands (or .adhdev/worktree_bootstrap.json) in repo mesh/refine config if Refinery should install/bootstrap before validation.',
|
|
1630
1726
|
}, false, {
|
|
1631
1727
|
exitCode: null,
|
|
1632
1728
|
skipped: true,
|
|
1633
1729
|
failureKind: 'missing_dependencies',
|
|
1634
1730
|
}));
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
summary.failureCode = 'missing_dependencies';
|
|
1638
|
-
return summary;
|
|
1731
|
+
missingDepsBlocked = true;
|
|
1732
|
+
continue;
|
|
1639
1733
|
}
|
|
1640
1734
|
// See the bootstrap loop above: resolve the win32 .cmd shim to an
|
|
1641
1735
|
// absolute path before handing it to the spawn boundary.
|
|
@@ -1681,6 +1775,18 @@ export async function runMeshRefineValidationGate(
|
|
|
1681
1775
|
}
|
|
1682
1776
|
}
|
|
1683
1777
|
|
|
1778
|
+
// (b) A command that genuinely needed deps could not run. Surface it as the
|
|
1779
|
+
// gate failure now (after letting no-dep / deps-present commands run), so the
|
|
1780
|
+
// caller can classify it blocked_review and emit a self-service hint. Every
|
|
1781
|
+
// daemon-scoped command in a web-only branch was already filtered above, so a
|
|
1782
|
+
// missing-deps block here is a real affected-command block.
|
|
1783
|
+
if (missingDepsBlocked) {
|
|
1784
|
+
summary.status = 'failed';
|
|
1785
|
+
summary.failureKind = 'missing_dependencies';
|
|
1786
|
+
summary.failureCode = 'missing_dependencies';
|
|
1787
|
+
return summary;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1684
1790
|
summary.status = 'passed';
|
|
1685
1791
|
return summary;
|
|
1686
1792
|
}
|
|
@@ -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;
|