Skip to content

Commit f0f59e5

Browse files
committed
feat(mesh): quota-aware routing — launch gate + bounded spread bonus
Consume the per-provider quota snapshots already riding each node's nodeFacts bundle in two routing decisions (previously observation-only): - GATE (mesh-quota-routing.evaluateProviderQuotaGate): after the auto-launch loop resolves a usable (node, provider) pair, skip it with 'provider_quota_session_low' / 'provider_quota_weekly_low' when a fresh, 'ok' snapshot shows the window below the mesh policy threshold (session 10% / weekly 15% remaining by default). Wait semantics: the reasons are deliberately non-actionable — the window resets, so the block self-resolves and the coordinator is not paged. - SPREAD (quotaSpreadBonusByProvider): a 0..spreadBonusMax (default 30) headroom bonus folded into task->slot fitness by the CALLERS, so scoreSlotForTask stays pure/synchronous and can never trigger a fetch. The cap sits below the exact-difficulty bonus (+100), so quota ranks equally-fit slots but never overturns a difficulty match. Fail-open contract: missing, non-ok, or STALE snapshots yield no gate and zero bonus. Staleness is judged clock-skew-safely: the snapshot age at report time (reportedAt - updatedAt) is a same-clock difference, and only the bundle transit age crosses clocks (clamped at >= 0); the default 30min threshold (two refresh cycles) absorbs residual skew. Thresholds live in mesh policy as quotaRouting { staleAfterMs, sessionMinRemainingPercent, weeklyMinRemainingPercent, spreadBonusMax } — all optional, defaults in DEFAULT_QUOTA_ROUTING_POLICY, persistence economy keeps untouched meshes.json byte-identical. This is the routing layer; the machine-local quota probe on/off stays in machineProviders. Also updates the now-stale OBSERVATION ONLY comments (mesh-shared node-facts, mcp-server status/compact surfaces).
1 parent 0485128 commit f0f59e5

10 files changed

Lines changed: 691 additions & 24 deletions

File tree

packages/daemon-core/src/mesh/mesh-events-coordinator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
__orderEligibleNodesForTests,
1616
__resolveSchedulingStrategyForTests,
1717
__resetIdleAutoFastForwardForTests,
18+
__scoreSlotForTaskForTests,
1819
activeReadonlyAssignedCount,
1920
activeWriteAssignedCount,
2021
isSessionActivelyGenerating,

packages/daemon-core/src/mesh/mesh-queue-assignment.ts

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmu
1717
import { delegatedWorkerAutoApproveSettings, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks, resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js';
1818
import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
1919
import type { RepoMeshDeclarativeConfig } from '../config/mesh-json-config.js';
20-
import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
20+
import type { RepoMeshSchedulingStrategy, RepoMeshQuotaRoutingPolicy } from '../repo-mesh-types.js';
2121
import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
2222
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
23+
import { evaluateProviderQuotaGate, quotaSpreadBonusByProvider } from './mesh-quota-routing.js';
2324
import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
2425
import { readNonEmptyString } from './mesh-events-utils.js';
2526
import { readMeshNodeDaemonId, isMeshNodeHealthLaunchable, isMeshNodeFreshEnoughToLaunch } from './mesh-node-identity.js';
@@ -1214,7 +1215,8 @@ const AUTO_LAUNCH_LEDGER_DEDUP_MAX = 2000;
12141215
// for a dispatch that can never happen. We now actively surface those — and ONLY those — as a
12151216
// pending coordinator event carrying a "why + how to act" message, routed to the originating
12161217
// coordinator (sourceCoordinator*). Transient/back-pressure skips (cooldown, in-progress,
1217-
// awaiting-claim, parallel/session caps, node not yet launch-ready, an active assignment) are
1218+
// awaiting-claim, parallel/session caps, node not yet launch-ready, an active assignment,
1219+
// provider quota below threshold) are
12181220
// deliberately excluded: they clear on their own and would only spam the coordinator every 4s.
12191221
const ACTIONABLE_SKIP_REASON_PREFIXES = [
12201222
'target_node_id_unmatched',
@@ -1233,6 +1235,10 @@ const ACTIONABLE_SKIP_REASON_PREFIXES = [
12331235
// busy counterpart SLOT_MODEL_BUSY_SKIP_REASON is deliberately NOT listed:
12341236
// that one clears on its own when the slot goes idle.
12351237
SLOT_MODEL_ABSENT_SKIP_REASON,
1238+
// QUOTA GATE: 'provider_quota_session_low' / 'provider_quota_weekly_low' are
1239+
// deliberately NOT listed either — an exhausted quota window RESETS, so the
1240+
// block self-resolves exactly like the slot-busy case; the task waits in the
1241+
// queue and the coordinator is not paged (mesh-quota-routing.ts).
12361242
];
12371243

12381244
// FALSE-BLOCKER-CLONE-QUEUE: the TRANSIENT counterpart of 'target_node_id_unmatched'. A
@@ -1786,7 +1792,7 @@ export function __orderEligibleNodesForTests(
17861792
meshId: string,
17871793
strategy: RepoMeshSchedulingStrategy,
17881794
nodes: RankableNode[],
1789-
opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
1795+
opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] }; quotaRouting?: RepoMeshQuotaRoutingPolicy | null },
17901796
): RankableNode[] {
17911797
return orderEligibleNodes(meshId, strategy, nodes, opts);
17921798
}
@@ -1866,8 +1872,17 @@ interface FitnessTask {
18661872
* (no declared difficulty) is a valid fallback; a slot whose capability tags cover
18671873
* the task's requiredTags gets a capability bonus. Never negative — the worst a
18681874
* slot does is score 0 (still selectable as a last-resort fallback).
1875+
*
1876+
* `quotaBonus` is the QUOTA SPREAD axis (mesh-quota-routing.ts): a bounded
1877+
* 0..spreadBonusMax headroom preference for the slot's provider, computed by
1878+
* the CALLER from the node's reported facts and passed in as a plain number.
1879+
* The scorer stays pure and synchronous — it never reads node facts itself and
1880+
* can never trigger a quota fetch. The default cap (30) sits below the exact
1881+
* difficulty-match bonus (+100), so quota can rank equally-fit slots but can
1882+
* never overturn a difficulty match. Callers pass 0 (or omit it) when no fresh
1883+
* quota reading exists, which reproduces the pre-feature scores exactly.
18691884
*/
1870-
function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
1885+
function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask, quotaBonus = 0): number {
18711886
let score = 1; // base: any slot can run the task (fallback floor)
18721887
const diff = isMeshTaskDifficulty(task.difficulty) ? task.difficulty as MeshTaskDifficulty : undefined;
18731888
if (diff) {
@@ -1883,24 +1898,25 @@ function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
18831898
const covered = req.every(t => cap.has(t));
18841899
score += covered ? 30 : 0; // capability coverage bonus (hard filter is applied elsewhere)
18851900
}
1901+
score += quotaBonus; // quota-headroom preference (0 when unknown/stale — see mesh-quota-routing.ts)
18861902
return score;
18871903
}
18881904

18891905
/** Best (slot, score) for a task on a node, or null when the node has no slots. */
1890-
function bestSlotForTask(node: any, task: FitnessTask, meshId?: string): { slot: NodeCapabilitySlot; score: number } | null {
1906+
function bestSlotForTask(node: any, task: FitnessTask, meshId?: string, quotaBonusByProvider?: Record<string, number>): { slot: NodeCapabilitySlot; score: number } | null {
18911907
const slots = resolveNodeCapabilitySlots(node, meshId);
18921908
if (!slots.length) return null;
18931909
let best: { slot: NodeCapabilitySlot; score: number } | null = null;
18941910
for (const slot of slots) {
1895-
const score = scoreSlotForTask(slot, task);
1911+
const score = scoreSlotForTask(slot, task, quotaBonusByProvider?.[slot.provider] ?? 0);
18961912
if (!best || score > best.score) best = { slot, score };
18971913
}
18981914
return best;
18991915
}
19001916

19011917
/** Node-level fitness for a task = its best slot's score (0 when the node has no slots). */
1902-
function nodeFitnessForTask(node: any, task: FitnessTask, meshId?: string): number {
1903-
return bestSlotForTask(node, task, meshId)?.score ?? 0;
1918+
function nodeFitnessForTask(node: any, task: FitnessTask, meshId?: string, quotaBonusByProvider?: Record<string, number>): number {
1919+
return bestSlotForTask(node, task, meshId, quotaBonusByProvider)?.score ?? 0;
19041920
}
19051921

19061922
/**
@@ -1959,7 +1975,7 @@ function orderEligibleNodes(
19591975
meshId: string,
19601976
strategy: RepoMeshSchedulingStrategy,
19611977
nodes: RankableNode[],
1962-
opts?: { bumpCursor?: boolean; task?: FitnessTask },
1978+
opts?: { bumpCursor?: boolean; task?: FitnessTask; quotaRouting?: RepoMeshQuotaRoutingPolicy | null },
19631979
): RankableNode[] {
19641980
if (strategy === 'first_eligible' || nodes.length <= 1) {
19651981
return nodes;
@@ -1968,11 +1984,23 @@ function orderEligibleNodes(
19681984
// Fitness strategy: rank by task→slot fit first (when a task is in scope —
19691985
// auto-launch drains per-task), then fall through to priority/load/rotation for
19701986
// ties. Without a task (idle-session drain ranks task-independently) fitness is
1971-
// inert and this behaves like least_loaded.
1987+
// inert and this behaves like least_loaded. The QUOTA SPREAD axis rides the
1988+
// fitness score here: each node's per-provider headroom bonus is computed once
1989+
// per pass from its reported facts (fail-open: missing/stale quota scores the
1990+
// pre-feature 0) and folded in via nodeFitnessForTask.
19721991
if (strategy === 'fitness' && opts?.task) {
19731992
const task = opts.task;
1993+
const bonusCache = new Map<string, Record<string, number>>();
1994+
const bonusFor = (c: RankableNode): Record<string, number> => {
1995+
let bonus = bonusCache.get(c.nodeId);
1996+
if (!bonus) {
1997+
bonus = quotaSpreadBonusByProvider(c.node, opts.quotaRouting);
1998+
bonusCache.set(c.nodeId, bonus);
1999+
}
2000+
return bonus;
2001+
};
19742002
return [...nodes].sort((a, b) => {
1975-
const fitDelta = nodeFitnessForTask(b.node, task, meshId) - nodeFitnessForTask(a.node, task, meshId);
2003+
const fitDelta = nodeFitnessForTask(b.node, task, meshId, bonusFor(b)) - nodeFitnessForTask(a.node, task, meshId, bonusFor(a));
19762004
if (fitDelta !== 0) return fitDelta; // higher fitness first
19772005
const prioDelta = resolveNodeSchedulingPriority(b.node?.policy) - resolveNodeSchedulingPriority(a.node?.policy);
19782006
if (prioDelta !== 0) return prioDelta;
@@ -2340,6 +2368,7 @@ async function resolveUsableProvider(
23402368
meshId: string | undefined,
23412369
requiredTags?: string[],
23422370
task?: FitnessTask,
2371+
quotaRouting?: RepoMeshQuotaRoutingPolicy | null,
23432372
): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; slot?: NodeCapabilitySlot; reason?: string }> {
23442373
const providerLoader = components.providerLoader;
23452374
if (!providerLoader) return { reason: 'provider_loader_unavailable' };
@@ -2348,10 +2377,15 @@ async function resolveUsableProvider(
23482377
// slots by task→slot fitness (difficulty/requiredTags) so the best-fit slot's
23492378
// provider is tried first, and its model/thinkingLevel ride along. Falls back
23502379
// to the legacy providerPriority-derived slots when no explicit slots exist.
2380+
// The QUOTA SPREAD bonus folds into the fitness score as a per-provider number
2381+
// computed HERE (the caller side) so scoreSlotForTask itself stays pure.
23512382
const slots = resolveNodeCapabilitySlots(node, meshId);
23522383
if (!slots.length) return { reason: 'missing_provider_priority' };
2384+
const quotaBonusByProvider = task ? quotaSpreadBonusByProvider(node, quotaRouting) : undefined;
23532385
const orderedSlots = task
2354-
? [...slots].sort((a, b) => scoreSlotForTask(b, task) - scoreSlotForTask(a, task))
2386+
? [...slots].sort((a, b) =>
2387+
scoreSlotForTask(b, task, quotaBonusByProvider?.[b.provider] ?? 0)
2388+
- scoreSlotForTask(a, task, quotaBonusByProvider?.[a.provider] ?? 0))
23552389
: slots;
23562390

23572391
const failed: string[] = [];
@@ -2438,6 +2472,16 @@ export function __isActionableSkipReasonForTests(reason: string): boolean {
24382472
return isActionableSkipReason(reason);
24392473
}
24402474

2475+
/** Test hook: the pure task→slot fitness scorer, including the quota-spread
2476+
* axis, exposed so tests can pin the bonus ordering/cap without a live daemon. */
2477+
export function __scoreSlotForTaskForTests(
2478+
slot: NodeCapabilitySlot,
2479+
task: { difficulty?: string; requiredTags?: string[] },
2480+
quotaBonus = 0,
2481+
): number {
2482+
return scoreSlotForTask(slot, task, quotaBonus);
2483+
}
2484+
24412485
// Canonical mesh node-id normalization. A node may arrive from the local config
24422486
// form (`id`) or the inline-cache form (`nodeId`/`node_id`) — see
24432487
// readInlineMeshNodeId in commands/router.ts. Comparing only `node.id` against a
@@ -2765,8 +2809,10 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
27652809
.map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
27662810
.filter((c: RankableNode) => c.nodeId),
27672811
// Auto-launch drains one task at a time, so the task IS in scope here —
2768-
// pass it through for the 'fitness' strategy's task→slot ranking.
2769-
{ bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags } },
2812+
// pass it through for the 'fitness' strategy's task→slot ranking. The
2813+
// mesh's quotaRouting thresholds ride along so the fitness score can
2814+
// include the quota-headroom spread bonus (fail-open when unset).
2815+
{ bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags }, quotaRouting: mesh?.policy?.quotaRouting ?? null },
27702816
).map((c: RankableNode) => c.node);
27712817

27722818
// LEDGER-TASK-TRACEABILITY (A): accumulate the candidate nodes that were
@@ -2856,11 +2902,26 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
28562902

28572903
autoLaunchInProgress.add(launchKey);
28582904
try {
2859-
const resolved = await resolveUsableProvider(components, nodeId, node, meshId, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags });
2905+
const resolved = await resolveUsableProvider(components, nodeId, node, meshId, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags }, mesh?.policy?.quotaRouting ?? null);
28602906
if (!resolved.providerType) {
28612907
markSkip(nodeId, resolved.reason || 'provider_unusable');
28622908
continue;
28632909
}
2910+
// QUOTA GATE: the (node, provider) pair is now definitive — the only
2911+
// point where a per-provider quota verdict can be applied. A fresh,
2912+
// 'ok' snapshot showing the session/weekly window below the mesh's
2913+
// quotaRouting threshold skips the pair. This is WAIT semantics (the
2914+
// window resets, so the block clears on its own): the reason is NOT
2915+
// actionable, the task stays queued, and the coordinator is not
2916+
// paged. Missing/unreadable/STALE snapshots fail OPEN — routing on
2917+
// an old reading would exclude nodes on data that no longer
2918+
// describes them (see mesh-quota-routing.ts).
2919+
const quotaBlock = evaluateProviderQuotaGate(node, resolved.providerType, mesh?.policy?.quotaRouting ?? null);
2920+
if (quotaBlock) {
2921+
LOG.info('MeshQueue', `QUOTA GATE: provider '${resolved.providerType}' on node ${nodeId} has ${quotaBlock.remainingPercent.toFixed(1)}% ${quotaBlock.window} quota remaining (< ${quotaBlock.thresholdPercent}% threshold, task ${task.id}); leaving the task queued until the window resets`);
2922+
markSkip(nodeId, quotaBlock.reason, { providerType: resolved.providerType });
2923+
continue;
2924+
}
28642925
// Slot-derived model/thinking precedence (see resolveLaunchAxis):
28652926
// an EXPLICIT task.model/thinkingLevel always wins; a
28662927
// PRESET-stamped one yields to the difficulty-covering slot's
@@ -3052,7 +3113,7 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
30523113
const requiredTags = Array.isArray(task.requiredTags) ? task.requiredTags.filter((t): t is string => !!t) : [];
30533114
const routingDecision: MeshTaskRoutingDecision = {
30543115
source: 'autoLaunch',
3055-
fitnessScore: nodeFitnessForTask(node, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags }, meshId),
3116+
fitnessScore: nodeFitnessForTask(node, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags }, meshId, quotaSpreadBonusByProvider(node, mesh?.policy?.quotaRouting ?? null)),
30563117
...(skippedCandidates.length ? { skippedCandidates } : {}),
30573118
requiredTagsResult: {
30583119
required: requiredTags,

0 commit comments

Comments
 (0)