bullswarm 0.28.6 → 0.28.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.28.7 — pools about to reset spend their leftover first
4
+
5
+ - routing: quota that expires sooner is worth more. A pool whose pacing window
6
+ resets within 24 hours (weekly) or 3 days (monthly) is now ranked on urgency
7
+ — its effective surplus divided by the fraction of the window still to run —
8
+ ahead of every pool whose window is not about to close, instead of on the
9
+ surplus alone. At 2026-09-11 12:26 HKT the medium lane went to
10
+ `claude-code:wati` (+22.9 points, 13h33m and 8.1% of its week left) over
11
+ `grok` (+13.8 points, 2h02m and 1.2% left, urgency ~1150 against wati's
12
+ ~283), and grok's points expired unspent two hours later; the owner had been
13
+ pinning grok by hand for such runs. Three states for an expiring pool:
14
+ `urgent` (surplus still to spend and a pacing forecast — the reading plus
15
+ in-flight work plus this candidate, each clipped at the reset — below 95%,
16
+ with 5 points of extra headroom demanded when no spend rate was measured),
17
+ which outranks incumbency and a configured effort assignment but never a
18
+ strict pin or the 5-hour rules; `draining` (forecast at/above 95%), ranked
19
+ behind every normal pool so a window about to be emptied is not fed one more
20
+ run; and `normal` (on or ahead of pace), ranked exactly as today. A pool with
21
+ no parsable `paceResetsAt`, a reset already past, or any other window is
22
+ never expiring soon and nothing about it changes. `bullswarm pools` ends an
23
+ expiring pool's line with `resets in 2h02m EXPIRING-SOON urgency=1150`, and
24
+ each routing candidate row carries `paceResetsInMinutes`, `expiringSoon`,
25
+ `urgency`, `forecastPacingPct` and `urgencyState`.
26
+
3
27
  ## 0.28.6 — the 5-hour near-limit line reads the clock
4
28
 
5
29
  - routing: the 5-hour near-limit line is now clock-relative. A pool is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.28.6",
3
+ "version": "0.28.7",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -3,7 +3,9 @@
3
3
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { homedir, tmpdir } from 'node:os';
6
- import { fiveHourElapsedPct, pickPool } from './lib/route.js';
6
+ import {
7
+ expiringSoonView, fiveHourElapsedPct, formatResetsIn, pickPool,
8
+ } from './lib/route.js';
7
9
  import { argvWithModel, watchOnce } from './lib/watch.js';
8
10
  import {
9
11
  isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
@@ -121,13 +123,21 @@ async function cmdPools(opts) {
121
123
  ? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%${clock}`)
122
124
  : ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}${clock}`;
123
125
  const nearLimit = p.nearFiveHourLimit === true ? ' NEAR-5H-LIMIT' : '';
126
+ // R11: a pacing window about to reset is quota about to be lost, so say
127
+ // when it closes and how urgent what is left has become. Pools whose
128
+ // window is not closing soon print nothing extra.
129
+ const expiring = expiringSoonView(p, { now });
130
+ const expiringNote = expiring.expiringSoon
131
+ ? ` resets in ${formatResetsIn(expiring.minutesToReset)} EXPIRING-SOON`
132
+ + ` urgency=${Math.round(expiring.urgency)}`
133
+ : '';
124
134
  const status = !p.enabled
125
135
  ? 'disabled'
126
136
  : p.quarantine
127
137
  ? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
128
138
  : `ready${burst}${nearLimit}`;
129
139
  console.log(
130
- `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}`,
140
+ `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}${expiringNote}`,
131
141
  );
132
142
  }
133
143
  return 0;
package/src/help.js CHANGED
@@ -326,11 +326,15 @@ const poolsText = rich({
326
326
  + 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
327
327
  + 'expected to push the window further; routing decides on the right-hand number. '
328
328
  + 'A trailing `(<n>% elapsed)` is how much of that 5-hour window has already run: '
329
- + 'routing only deprioritizes a near-limit pool whose usage is ahead of its clock.',
329
+ + 'routing only deprioritizes a near-limit pool whose usage is ahead of its clock. '
330
+ + 'A pool whose weekly window resets within 24 hours, or whose monthly window resets '
331
+ + 'within 3 days, ends its line with `resets in <Nd Nh|Nh Nm|Nm> EXPIRING-SOON '
332
+ + 'urgency=<n>`: quota about to be lost, scored as surplus divided by the fraction '
333
+ + 'of the window still to run, which is what routing ranks it on.',
330
334
  args: [],
331
335
  options: [
332
336
  { flag: '--force', desc: 'bypass the meter cache and re-read live usage for every pool', default: 'off (cached meter readings reused within their TTL)' },
333
- { flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly, monthly, pacing} rates with their source and sample count, pacingWindow, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
337
+ { flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly, monthly, pacing} rates with their source and sample count, pacingWindow, paceResetsAt, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
334
338
  ],
335
339
  safety: [
336
340
  'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
package/src/lib/route.js CHANGED
@@ -55,6 +55,42 @@
55
55
  // resets_at, an unparsable one, or a reset already in the past means no
56
56
  // clock: R7 keeps its fixed line and nothing is clipped, because a pool
57
57
  // is never treated differently for a number nobody produced (R8).
58
+ // R11. Quota that expires sooner is worth more ("expiring soon"). A pace
59
+ // surplus is a difference in points and says nothing about how long the
60
+ // pool has left to spend it. (Observed 2026-09-11T04:26Z: grok held
61
+ // +13.8 weekly points with 2h02m left in its week — 1.2% of the window —
62
+ // while claude-code:wati held +22.9 with 13h33m left (8.1%). R2 sent the
63
+ // run to wati on 22.9 > 13.8, and grok's 15 points expired two hours
64
+ // later; the owner had been pinning grok by hand.) So a pool whose
65
+ // PACING window resets within a fixed lead time — EXPIRING_SOON_MS: 24
66
+ // hours weekly, 3 days monthly, the owner's chosen values, about a
67
+ // seventh of a week and a tenth of a month — is ranked on
68
+ // urgency = effective surplus / the fraction of its window still to run
69
+ // (floored at MIN_WINDOW_LEFT_FRACTION so a reset seconds away cannot
70
+ // divide by zero) instead of on the surplus alone. Three states:
71
+ // urgent — surplus still to spend and a pacing forecast (the
72
+ // reading, plus in-flight work and this candidate, each
73
+ // clipped at the pacing reset exactly as R10 clips the 5h
74
+ // window) below PACING_FORECAST_BLOCK_PCT; with no measured
75
+ // rate the reading must also sit 5 points under that line,
76
+ // because an unmeasured pool's forecast is only its
77
+ // reading. Ranks ahead of every pool not expiring soon.
78
+ // draining — forecast at/above the line: ranked after every normal
79
+ // pool and chosen only when nothing else is eligible, so a
80
+ // pool about to be emptied is not fed one more run that
81
+ // would push it over the wall.
82
+ // normal — expiring soon but on or ahead of pace: ranked with
83
+ // everyone else on effective surplus, exactly as today.
84
+ // Urgency outranks incumbency (R3/R4/R9) and a configured effort
85
+ // assignment (preferredPool) by the same mechanism R7's tier uses —
86
+ // selection happens among the urgent pools while one exists — so an
87
+ // urgent challenger needs neither the 10-point margin nor the cost
88
+ // guard. It never overrides a strict pin (workflow strictPool filters
89
+ // the pool list before pickPool ever sees it), and never the 5h rules:
90
+ // a pool tiered down by R7/R10 or gated by R8 is not rescued by
91
+ // urgency. No pacing window, no parsable paceResetsAt, a reset already
92
+ // in the past, or any window other than weekly/monthly means there is
93
+ // no lead time to measure and nothing about the pool changes (R8).
58
94
 
59
95
  import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT, WINDOW_MS } from '../meters/framework.js';
60
96
  // One strict numeric coercion for the whole codebase (src/lib/num.js): a
@@ -75,6 +111,40 @@ export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
75
111
  */
76
112
  export const DEFAULT_INFLIGHT_PENALTY_PCT = 3;
77
113
 
114
+ /**
115
+ * R11 lead times: how close a pacing window's reset has to be before the pool
116
+ * counts as "expiring soon". The owner's chosen values — roughly a seventh of
117
+ * a week and a tenth of a month — long enough that a run dispatched now can
118
+ * still use the quota, short enough that the pool really is about to lose it.
119
+ * Any window that is not one of these keys is never expiring soon.
120
+ */
121
+ export const EXPIRING_SOON_MS = {
122
+ weekly: 24 * 3600_000,
123
+ monthly: 72 * 3600_000,
124
+ };
125
+
126
+ /**
127
+ * Pacing-window forecast at/above which an expiring-soon pool is `draining`
128
+ * rather than `urgent`: its window is about to close AND about to be emptied,
129
+ * so one more run spends the run's next attempt on a quota failure.
130
+ */
131
+ export const PACING_FORECAST_BLOCK_PCT = 95;
132
+
133
+ /**
134
+ * Smallest window-left fraction urgency will divide by (0.5% of the window).
135
+ * A reset thirty seconds away is 0.005% of a week: without a floor the score
136
+ * would be Infinity-shaped and one pool would swallow every lane.
137
+ */
138
+ export const MIN_WINDOW_LEFT_FRACTION = 0.005;
139
+
140
+ /**
141
+ * Points of headroom an UNMEASURED expiring-soon pool needs below
142
+ * PACING_FORECAST_BLOCK_PCT to be called urgent. With no spend rate its
143
+ * forecast is only its reading plus a flat penalty, so the last few points
144
+ * before the line are exactly where that estimate is least trustworthy.
145
+ */
146
+ export const UNMEASURED_URGENT_HEADROOM_PCT = 5;
147
+
78
148
  export function elapsedPct(meter, now = Date.now()) {
79
149
  if (!meter || meter.type === 'none') return 0;
80
150
  const start = meter.windowStart ?? 0;
@@ -297,9 +367,7 @@ export function inflightLoad(pool, opts = {}) {
297
367
  const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
298
368
  // The pacing window's rate, or the weekly one when that window has no
299
369
  // measured rate — the surplus and the penalty stay on the same window.
300
- const paced = num(pool?.spend?.pacing?.ratePerMinute) != null
301
- ? pool.spend.pacing
302
- : pool?.spend?.weekly ?? null;
370
+ const paced = pacingRateBlock(pool);
303
371
  const rate = num(paced?.ratePerMinute);
304
372
  const minutes = num(candidateMinutes);
305
373
  const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
@@ -342,6 +410,201 @@ export function inflightLoad(pool, opts = {}) {
342
410
  return { count, penalty, ratePerMinute: rate, estimateSource };
343
411
  }
344
412
 
413
+ /**
414
+ * The spend block whose rate paces this pool: `spend.pacing` when it carries a
415
+ * measured rate, else `spend.weekly`. Charging a weekly rate against a monthly
416
+ * surplus would compare points from two different windows, so this is the only
417
+ * fallback — and it is the one inflightLoad() has always used, shared here so
418
+ * the pacing forecast (R11) charges the same rate the ranking charges.
419
+ */
420
+ function pacingRateBlock(pool) {
421
+ return num(pool?.spend?.pacing?.ratePerMinute) != null
422
+ ? pool.spend.pacing
423
+ : pool?.spend?.weekly ?? null;
424
+ }
425
+
426
+ /**
427
+ * Minutes until this pool's PACING window (weekly or monthly) resets, from
428
+ * `pool.paceResetsAt` (src/lib/config.js, straight off the meter reading).
429
+ *
430
+ * null when there is no reading, when it cannot be parsed, or when the reset
431
+ * is already at/behind `now` — an outrun deadline is unknown, not a
432
+ * zero-length window (R8/R11). The 5h twin is minutesUntilFiveHourReset().
433
+ */
434
+ export function minutesUntilPacingReset(pool, now = Date.now()) {
435
+ const resetsAtMs = Date.parse(pool?.paceResetsAt ?? '');
436
+ if (!Number.isFinite(resetsAtMs)) return null;
437
+ const minutes = (resetsAtMs - now) / 60_000;
438
+ return minutes > 0 ? minutes : null;
439
+ }
440
+
441
+ /** `5d22h`, `2h02m`, `45m` — how long a window has left, for humans (R11). */
442
+ export function formatResetsIn(minutes) {
443
+ const total = Math.max(0, Math.round(num(minutes) ?? 0));
444
+ const days = Math.floor(total / 1440);
445
+ const hours = Math.floor((total % 1440) / 60);
446
+ const mins = total % 60;
447
+ if (days > 0) return `${days}d${hours}h`;
448
+ if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`;
449
+ return `${mins}m`;
450
+ }
451
+
452
+ /**
453
+ * In-flight minutes that fall INSIDE the pacing window still to run — the
454
+ * complement of inflightOverflowMinutes(). Everything after the reset is the
455
+ * next window's problem (R11, mirroring R10).
456
+ */
457
+ function inflightMinutesWithin(pool, minutesToReset) {
458
+ const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
459
+ let inside = 0;
460
+ for (const record of records) {
461
+ const m = num(record?.remainingMinutes);
462
+ if (m == null) continue;
463
+ const kept = Math.max(0, m);
464
+ inside += minutesToReset == null ? kept : Math.min(kept, minutesToReset);
465
+ }
466
+ return inside;
467
+ }
468
+
469
+ /**
470
+ * What this pool's PACING window (weekly or monthly) will read once the work
471
+ * it is already carrying and the assignment being routed have landed (R11):
472
+ *
473
+ * forecast = (projectedPacingPct ?? usedPct)
474
+ * − ratePerMinute × in-flight minutes past the pacing reset
475
+ * + ratePerMinute × min(candidateMinutes, minutes to the reset)
476
+ *
477
+ * Spend that lands after the reset belongs to the NEXT window, so both terms
478
+ * are clipped at `paceResetsAt` exactly as fiveHourForecast() clips at the 5h
479
+ * one: the producer's projection (src/lib/spend.js) charges every remaining
480
+ * in-flight minute to this window, and the minutes past the reset are credited
481
+ * back out of it — never below the pool's own reading. When the producer
482
+ * attached no projection the same in-flight minutes are added to the reading
483
+ * instead, which is the identical number by another route.
484
+ *
485
+ * With no measured rate there is nothing to multiply by: the forecast is the
486
+ * reading plus the flat per-agent penalty inflightLoad() already charges, and
487
+ * a pool with no reading at all forecasts null (unknown — R8).
488
+ *
489
+ * @returns {{raw: number|null, projected: number|null,
490
+ * ratePerMinute: number|null, candidateAdd: number|null,
491
+ * inflightCreditPct: number, minutesToReset: number|null,
492
+ * forecast: number|null}}
493
+ */
494
+ export function pacingForecast(pool, candidateMinutes = null, now = Date.now(), opts = {}) {
495
+ const { inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT } = opts;
496
+ const raw = num(pool?.usedPct);
497
+ const projected = num(pool?.projectedPacingPct);
498
+ const ratePerMinute = num(pacingRateBlock(pool)?.ratePerMinute);
499
+ const minutes = num(candidateMinutes);
500
+ const minutesToReset = minutesUntilPacingReset(pool, now);
501
+ const base = projected ?? raw;
502
+ const empty = {
503
+ raw, projected, ratePerMinute, candidateAdd: null, inflightCreditPct: 0, minutesToReset,
504
+ };
505
+ if (base == null) return { ...empty, forecast: null };
506
+ if (ratePerMinute == null) {
507
+ const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
508
+ const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
509
+ return { ...empty, forecast: base + count * penaltyPct };
510
+ }
511
+ const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
512
+ const candidateAdd = minutes == null ? 0 : ratePerMinute * clip(Math.max(0, minutes));
513
+ const overflowMinutes =
514
+ minutesToReset == null ? 0 : inflightOverflowMinutes(pool, minutesToReset);
515
+ const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
516
+ const carried =
517
+ projected == null
518
+ ? raw + ratePerMinute * inflightMinutesWithin(pool, minutesToReset)
519
+ : inflightCreditPct > 0
520
+ ? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
521
+ : projected;
522
+ return {
523
+ ...empty,
524
+ candidateAdd,
525
+ inflightCreditPct,
526
+ forecast: carried + candidateAdd,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * R11 view of one pool: is its pacing window about to close, how urgent is the
532
+ * quota it still holds, and what will that window read once in-flight work and
533
+ * this candidate land.
534
+ *
535
+ * `effective` is the ranking's own `pace − load.penalty`; pickPool passes the
536
+ * number it already computed, and any other caller (bullswarm pools) lets this
537
+ * recompute it from the pool.
538
+ *
539
+ * `windowLeftFraction` is (100 − elapsedPct) / 100, floored at
540
+ * MIN_WINDOW_LEFT_FRACTION. A pool whose reading carries no elapsedPct has no
541
+ * measured window position, so the fraction is 1 and urgency is just the
542
+ * surplus — never inflated for a number nobody produced (R8).
543
+ *
544
+ * @returns {{expiringSoon: boolean, window: string|null,
545
+ * minutesToReset: number|null, windowLeftFraction: number|null,
546
+ * effective: number|null, urgency: number|null,
547
+ * forecast: number|null, ratePerMinute: number|null,
548
+ * state: 'urgent'|'normal'|'draining'|null}}
549
+ */
550
+ export function expiringSoonView(pool, opts = {}) {
551
+ const {
552
+ now = Date.now(),
553
+ candidateMinutes = null,
554
+ inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT,
555
+ effective = null,
556
+ } = opts;
557
+ const window = pool?.pacingWindow ?? null;
558
+ const leadMs = EXPIRING_SOON_MS[window] ?? null;
559
+ const minutesToReset = minutesUntilPacingReset(pool, now);
560
+ const notSoon = {
561
+ expiringSoon: false,
562
+ window,
563
+ minutesToReset,
564
+ windowLeftFraction: null,
565
+ effective: null,
566
+ urgency: null,
567
+ forecast: null,
568
+ ratePerMinute: null,
569
+ state: null,
570
+ };
571
+ if (leadMs == null || minutesToReset == null || minutesToReset * 60_000 > leadMs) {
572
+ return notSoon;
573
+ }
574
+
575
+ const eff =
576
+ num(effective)
577
+ ?? paceScore(pool, now) - inflightLoad(pool, { candidateMinutes, inflightPenaltyPct }).penalty;
578
+ const elapsed = num(pool?.elapsedPct);
579
+ const windowLeftFraction = Math.max(
580
+ MIN_WINDOW_LEFT_FRACTION,
581
+ elapsed == null ? 1 : (100 - elapsed) / 100,
582
+ );
583
+ const pacing = pacingForecast(pool, candidateMinutes, now, { inflightPenaltyPct });
584
+ const forecast = pacing.forecast;
585
+ const used = num(pool?.usedPct);
586
+ // An unmeasured pool's forecast is its reading: demand real headroom before
587
+ // handing it the lane ahead of everyone else.
588
+ const trusted =
589
+ pacing.ratePerMinute != null ||
590
+ (used != null && used <= PACING_FORECAST_BLOCK_PCT - UNMEASURED_URGENT_HEADROOM_PCT);
591
+ const state =
592
+ forecast != null && forecast >= PACING_FORECAST_BLOCK_PCT ? 'draining'
593
+ : eff > 0 && forecast != null && trusted ? 'urgent'
594
+ : 'normal';
595
+ return {
596
+ expiringSoon: true,
597
+ window,
598
+ minutesToReset,
599
+ windowLeftFraction,
600
+ effective: eff,
601
+ urgency: eff / windowLeftFraction,
602
+ forecast,
603
+ ratePerMinute: pacing.ratePerMinute,
604
+ state,
605
+ };
606
+ }
607
+
345
608
  export function isExhausted(pool) {
346
609
  // Flat shape (buildPools) first, legacy meter shape second. A stale
347
610
  // meterSource reading must not permanently exclude a pool: if the reading
@@ -423,15 +686,25 @@ export function pickPool(lane, pools, opts = {}) {
423
686
  const forecast = fiveHourForecast(p, candidateMins, now);
424
687
  const load = inflightLoad(p, { candidateMinutes: candidateMins, inflightPenaltyPct });
425
688
  const pace = paceScore(p, now);
689
+ // R8c: pace minus the quota this pool's in-flight work and this
690
+ // assignment are expected to spend. Equals pace when nothing is in
691
+ // flight and no rate applies.
692
+ const effective = pace - load.penalty;
693
+ // R11: the same surplus, divided by how much of the pacing window is left
694
+ // to spend it in. All-null for a pool whose window is not about to close.
695
+ const expiring = expiringSoonView(p, {
696
+ now, candidateMinutes: candidateMins, inflightPenaltyPct, effective,
697
+ });
426
698
  return {
427
699
  pool: p,
428
700
  pace,
429
- // R8c: pace minus the quota this pool's in-flight work and this
430
- // assignment are expected to spend. Equals pace when nothing is in
431
- // flight and no rate applies.
432
- effective: pace - load.penalty,
701
+ effective,
433
702
  load,
434
703
  forecast,
704
+ expiring,
705
+ // urgent first, draining last, everything else in the middle — the tier
706
+ // R11 adds under R7's 5h tier and above the pace comparison.
707
+ urgencyRank: expiring.state === 'urgent' ? 0 : expiring.state === 'draining' ? 2 : 1,
435
708
  // R8b: R7's tier, applied to the forecast instead of the reading — and
436
709
  // R10: only for a pool further through its 5h quota than through its 5h
437
710
  // window. A pool at 88% with 23 minutes left keeps its tier 0.
@@ -442,11 +715,18 @@ export function pickPool(lane, pools, opts = {}) {
442
715
  gated: forecast.forecasted && forecast.forecast != null && forecast.forecast >= BURST_BLOCK_PCT,
443
716
  };
444
717
  });
445
- // R8 before R7 before R2: forecast-gated pools last, then 5h headroom, then
446
- // most-behind-after-load within the tier. The candidate list is reported in
447
- // this exact preference order.
718
+ // R8 before R7 before R11 before R2: forecast-gated pools last, then 5h
719
+ // headroom, then urgent < normal < draining, then the group's own score —
720
+ // urgency among the urgent, most-behind-after-load everywhere else. The
721
+ // candidate list is reported in this exact preference order.
448
722
  scored.sort(
449
- (a, b) => (a.gated ? 1 : 0) - (b.gated ? 1 : 0) || a.tier - b.tier || b.effective - a.effective,
723
+ (a, b) =>
724
+ (a.gated ? 1 : 0) - (b.gated ? 1 : 0) ||
725
+ a.tier - b.tier ||
726
+ a.urgencyRank - b.urgencyRank ||
727
+ (a.urgencyRank === 0
728
+ ? b.expiring.urgency - a.expiring.urgency
729
+ : b.effective - a.effective),
450
730
  );
451
731
 
452
732
  const candidates = scored.map((e) => ({
@@ -472,6 +752,16 @@ export function pickPool(lane, pools, opts = {}) {
472
752
  estimateSource: e.load.estimateSource,
473
753
  nearFiveHourLimit: e.tier === 1,
474
754
  forecastGated: e.gated,
755
+ // R11: when the pacing window resets, whether that is close enough to
756
+ // count, and the urgency/forecast that decided the pool's standing. Every
757
+ // field but the first is null for a pool whose window is not about to
758
+ // close — nothing changes for a number nobody produced (R8).
759
+ paceResetsInMinutes:
760
+ e.expiring.minutesToReset == null ? null : tenth(e.expiring.minutesToReset),
761
+ expiringSoon: e.expiring.expiringSoon,
762
+ urgency: e.expiring.urgency == null ? null : tenth(e.expiring.urgency),
763
+ forecastPacingPct: e.expiring.forecast == null ? null : tenth(e.expiring.forecast),
764
+ urgencyState: e.expiring.state,
475
765
  }));
476
766
  const gatedNames = scored.filter((e) => e.gated).map((e) => e.pool.name);
477
767
  const forecastReport = { candidateMinutes: candidateMins, gated: gatedNames };
@@ -511,6 +801,7 @@ export function pickPool(lane, pools, opts = {}) {
511
801
 
512
802
  let winnerEntry;
513
803
  let skippedNearLimit = [];
804
+ let skippedDraining = [];
514
805
  if (allGated) {
515
806
  winnerEntry = [...scored].sort(
516
807
  (a, b) =>
@@ -520,9 +811,21 @@ export function pickPool(lane, pools, opts = {}) {
520
811
  } else {
521
812
  // R7: selection happens only among pools with 5h headroom while any exists.
522
813
  const withHeadroom = open.filter((e) => e.tier === 0);
523
- const selectable = withHeadroom.length ? withHeadroom : open;
814
+ const headroomSet = withHeadroom.length ? withHeadroom : open;
524
815
  skippedNearLimit = withHeadroom.length ? open.filter((e) => e.tier === 1) : [];
525
816
 
817
+ // R11, by the same mechanism and one rung below it: while any pool's
818
+ // quota is about to expire with room to spend it, that pool is the only
819
+ // selectable one — which is what puts urgency ahead of incumbency and of
820
+ // a configured effort assignment, both of which are resolved inside
821
+ // `selectable` below. A draining pool is the mirror image: out of
822
+ // selection until nothing else is left.
823
+ const urgentSet = headroomSet.filter((e) => e.urgencyRank === 0);
824
+ const notDraining = headroomSet.filter((e) => e.urgencyRank !== 2);
825
+ const selectable =
826
+ urgentSet.length ? urgentSet : notDraining.length ? notDraining : headroomSet;
827
+ skippedDraining = notDraining.length ? headroomSet.filter((e) => e.urgencyRank === 2) : [];
828
+
526
829
  const preferredEntry = preferredPool
527
830
  ? selectable.find((entry) => entry.pool.name === preferredPool)
528
831
  : null;
@@ -577,6 +880,7 @@ export function pickPool(lane, pools, opts = {}) {
577
880
  preferred: !allGated && Boolean(preferredPool) && winnerEntry.pool.name === preferredPool,
578
881
  effortTier: opts.effortTier,
579
882
  skippedNearLimit,
883
+ skippedDraining,
580
884
  gated: allGated ? [] : gatedEntries,
581
885
  gatedFallback: allGated,
582
886
  yieldedBusier,
@@ -653,6 +957,7 @@ function routingReason(
653
957
  preferred,
654
958
  effortTier,
655
959
  skippedNearLimit = [],
960
+ skippedDraining = [],
656
961
  gated = [],
657
962
  gatedFallback = false,
658
963
  yieldedBusier = [],
@@ -672,6 +977,11 @@ function routingReason(
672
977
  base = `configured ${effortTier ?? 'effort'} assignment (${
673
978
  [winnerEntry.pool.name, note, inflight].filter(Boolean).join(', ')
674
979
  })`;
980
+ } else if (winnerEntry.urgencyRank === 0) {
981
+ // R11: this pool did not win on the size of its surplus but on how little
982
+ // time is left to spend it, so the reason names the clock, the fraction of
983
+ // the window still to run, and the forecast that kept it out of draining.
984
+ base = urgencyClause(winnerEntry, [note, inflight].filter(Boolean).join(', '));
675
985
  } else {
676
986
  // Three states, not two (R10): headroom, near the limit and tiered down,
677
987
  // or near the limit but under the window's clock — where the note itself
@@ -697,6 +1007,15 @@ function routingReason(
697
1007
  `forecast-gated at/above ${BURST_BLOCK_PCT}%: ${gated.map((e) => poolPctLabel(e)).join(', ')}`,
698
1008
  );
699
1009
  }
1010
+ if (skippedDraining.length) {
1011
+ // R11: a pool whose window is about to close was passed over anyway,
1012
+ // because the run would spend what little it has left through the wall.
1013
+ clauses.push(
1014
+ `expiring but draining (forecast >= ${PACING_FORECAST_BLOCK_PCT}%): ${skippedDraining
1015
+ .map((e) => `${e.pool.name} ${pacingPctText(e)}`)
1016
+ .join(', ')}`,
1017
+ );
1018
+ }
700
1019
  if (yieldedBusier.length) {
701
1020
  clauses.push(
702
1021
  `preferred over busier: ${yieldedBusier
@@ -707,6 +1026,31 @@ function routingReason(
707
1026
  return clauses.join(' · ');
708
1027
  }
709
1028
 
1029
+ /**
1030
+ * R11's reason for an urgent winner:
1031
+ * `expiring soon: grok resets in 2h02m, surplus 13.8 over 1.2% of the week
1032
+ * left → urgency 1140, forecast 91.0%`. Urgency reads as a whole number: at
1033
+ * this scale a tenth of a point is noise, and the candidate row carries the
1034
+ * rounded value for anything that needs it.
1035
+ */
1036
+ function urgencyClause(entry, detail) {
1037
+ const { minutesToReset, windowLeftFraction, urgency, window } = entry.expiring;
1038
+ const word = window === 'monthly' ? 'month' : 'week';
1039
+ const left = tenth(windowLeftFraction * 100);
1040
+ const tail = detail ? ` (${detail})` : '';
1041
+ return (
1042
+ `expiring soon: ${entry.pool.name} resets in ${formatResetsIn(minutesToReset)}, `
1043
+ + `surplus ${tenth(entry.effective)} over ${left}% of the ${word} left `
1044
+ + `→ urgency ${Math.round(urgency)}, forecast ${pacingPctText(entry)}${tail}`
1045
+ );
1046
+ }
1047
+
1048
+ /** `91.0%` — an expiring-soon pool's pacing forecast, or `?%` with no reading. */
1049
+ function pacingPctText(entry) {
1050
+ const pct = entry.expiring.forecast;
1051
+ return pct == null ? '?%' : `${Number(pct).toFixed(1)}%`;
1052
+ }
1053
+
710
1054
  /**
711
1055
  * `<pool> <pct>%` using the forecast when one exists, else the raw reading —
712
1056
  * and, for a pool at/above the near-limit line, where its 5h window stands