@@ -17,9 +17,10 @@ import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmu
1717import { delegatedWorkerAutoApproveSettings , resolveProviderMaxParallel , resolveNodeSchedulingPriority , normalizeMeshSchedulingStrategy , resolveMaxParallelTasks , resolveMaxReadonlyParallelTasks , resolveCoordinatorIdlePushPolicy } from '../repo-mesh-types.js' ;
1818import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js' ;
1919import 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' ;
2121import { normalizeMeshNodeId , meshNodeIdMatches , daemonIdsEquivalent , canonicalDaemonId , expandDaemonIdForms , normalizeMeshWorkspaceForCompare , meshWorkspacesEquivalent , sessionIdsEquivalent , normalizeNodeCapabilitySlots , isMeshTaskDifficulty , withStatusProbeMarker , type MeshNodeIdentified , type NodeCapabilitySlot , type MeshTaskDifficulty } from '@adhdev/mesh-shared' ;
2222import { resolveNodeCapabilitySlots } from './mesh-node-slots.js' ;
23+ import { evaluateProviderQuotaGate , quotaSpreadBonusByProvider } from './mesh-quota-routing.js' ;
2324import { findTerminalLedgerEvidenceForTask , hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js' ;
2425import { readNonEmptyString } from './mesh-events-utils.js' ;
2526import { 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.
12191221const 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