bullswarm 0.12.0 → 0.12.1

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,22 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.12.1 — a burst-gated provider is waited for, never failed on the spot
4
+
5
+ - `workflow` runs no longer die with `no eligible pool` when every candidate
6
+ pool is burst-gated (provider 5-hour window ≥ 90 % used). The runtime parks
7
+ the dispatch in a new `waiting_for_quota` stage (`state.quotaWait` names the
8
+ pool, its 5h usage and reset time; events `dispatch.waiting_for_quota`,
9
+ `dispatch.quota_available`, `dispatch.quota_wait_expired`), re-reads the
10
+ provider meter every 60 s, and continues the moment the gate lifts. It gives
11
+ up — with the pool, usage and reset time in the failure reason — only after
12
+ the known reset time plus 10 min of grace (5 h when no reset time is known).
13
+ The planner's context is composed after the wait, so it never sees an empty
14
+ pool list. Observed 2026-08-28 19:09 Z: the first 0.12.0 comparison run
15
+ failed in 4 s because the account's Claude 5h window read 91 % (reset
16
+ 22:30 Z); Claude Code in the same situation waits on the rate limit.
17
+ Options for embedding callers/tests: `quotaPollMs`, `quotaWaitGraceMs`,
18
+ `quotaWaitUnknownResetMs` on `runWorkflow`; `readMeter` injection.
19
+
3
20
  ## 0.12.0 — one decision is a whole program
4
21
 
5
22
  Completes the convergence on Claude Code's dynamic-workflow mechanics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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/skill/SKILL.md CHANGED
@@ -363,6 +363,12 @@ finished action reported), and `workflow goal` runs a read-only `scout` action
363
363
  first (`--no-scout` to skip) so the first program is compiled from a real
364
364
  survey of the repository rather than from the goal text alone.
365
365
 
366
+ A burst-gated provider (5-hour window ≥ 90 % used) is a *wait*, not a
367
+ failure: the run parks in stage `waiting_for_quota` (`state.quotaWait` shows
368
+ the pool, usage and reset time), re-reads the meter every 60 s, and continues
369
+ when the window resets. Only after the reset time plus 10 min of grace does the
370
+ action fail, with the gate named in `why`.
371
+
366
372
  Allowed planner decisions are `proceed`, `complete`, `needs_more_work`,
367
373
  `retry`, `escalate`, `wait_for_approval`, and `stop`. Expansion decisions must
368
374
  contain bounded actions; malformed or over-budget output executes nothing.
@@ -258,6 +258,10 @@ export async function runWorkflow(opts) {
258
258
  runDir,
259
259
  onEvent: opts.onEvent,
260
260
  env: opts.env,
261
+ readMeter: opts.readMeter,
262
+ quotaPollMs: opts.quotaPollMs,
263
+ quotaWaitGraceMs: opts.quotaWaitGraceMs,
264
+ quotaWaitUnknownResetMs: opts.quotaWaitUnknownResetMs,
261
265
  });
262
266
  state.runner = {
263
267
  pid: process.pid,
@@ -35,6 +35,7 @@ import { aggregateUsage } from '../lib/usage.js';
35
35
  import { classifyAgentProgress, recordAgentAction } from '../lib/agent-events.js';
36
36
  import { deliverSteering } from './steering.js';
37
37
  import { resolveDispatchModel } from '../lib/strategy.js';
38
+ import { getMeterReading } from '../meters/registry.js';
38
39
 
39
40
  // Cap how much of each step's output we keep inline in state.json.
40
41
  // Persisting full transcripts bloat state.json on long workflows. The
@@ -45,6 +46,12 @@ const FANOUT_ITEM_EXCERPT_BYTES = 6_000;
45
46
  // What the planner sees of each action's output (per output / all outputs).
46
47
  const PLANNER_EXCERPT_CHARS = 3_000;
47
48
  const PLANNER_EXCERPT_TOTAL_CHARS = 36_000;
49
+ // A burst gate (provider 5h window >= 90 % used) is a WAIT, never a hard stop:
50
+ // the runtime parks the dispatch until the window resets (+ grace), re-reading
51
+ // the meter every QUOTA_POLL_MS, and only then fails with the reset time named.
52
+ export const BURST_WAIT_GRACE_MS = 10 * 60_000;
53
+ export const BURST_WAIT_UNKNOWN_RESET_MS = 5 * 3600_000;
54
+ export const QUOTA_POLL_MS = 60_000;
48
55
 
49
56
  export function plannerBudgetContext(budget = {}) {
50
57
  const dispatchesUsedBeforePlanner = Number(budget.dispatchesUsed ?? 0);
@@ -110,6 +117,11 @@ export class WorkflowRuntime {
110
117
  );
111
118
  this.limiter = concap;
112
119
  this.parentEnv = opts.env ?? process.env;
120
+ // Meter refresh used while waiting on a burst gate; tests inject a fake.
121
+ this.readMeter = opts.readMeter ?? ((name) => getMeterReading(name, { force: true }));
122
+ this.quotaPollMs = Math.max(10, Number(opts.quotaPollMs ?? this.state?.settings?.quotaPollMs ?? QUOTA_POLL_MS));
123
+ this.quotaWaitGraceMs = Math.max(0, Number(opts.quotaWaitGraceMs ?? BURST_WAIT_GRACE_MS));
124
+ this.quotaWaitUnknownResetMs = Math.max(0, Number(opts.quotaWaitUnknownResetMs ?? BURST_WAIT_UNKNOWN_RESET_MS));
113
125
  // Counters used for planner-visible advisory budgeting.
114
126
  this.state.attempts ??= [];
115
127
  this.state.actionLedger ??= [];
@@ -244,9 +256,13 @@ export class WorkflowRuntime {
244
256
  * before selection (R8).
245
257
  */
246
258
  async dispatch(step, taskText, targetDir, paths, opts = {}) {
259
+ if (this.state.cancelRequested) return { ok: false, keepOnClaude: false, why: 'workflow cancellation requested', pick: { pool: null }, meta: {} };
260
+ const effortTier = step.effort ?? ({ analyze: 'high', build: 'medium', chore: 'low' }[step.lane ?? 'chore']);
261
+ // A burst-gated provider is waited for (outside the concurrency permit),
262
+ // never failed on the spot.
263
+ await this.awaitBurstRoom(step, effortTier, this.actionId(step, opts));
247
264
  if (this.state.cancelRequested) return { ok: false, keepOnClaude: false, why: 'workflow cancellation requested', pick: { pool: null }, meta: {} };
248
265
  return this.limiter.runWith(async () => {
249
- const effortTier = step.effort ?? ({ analyze: 'high', build: 'medium', chore: 'low' }[step.lane ?? 'chore']);
250
266
  const attemptPools = this.preparePools(step, effortTier);
251
267
  let lastVerdict = null;
252
268
  const retryAllowance = Math.max(0, Math.min(Number(opts.retryAttempts ?? 1), 3));
@@ -269,10 +285,13 @@ export class WorkflowRuntime {
269
285
  });
270
286
  if (!route.pick) {
271
287
  if (lastVerdict) return lastVerdict;
288
+ const stillGated = attemptPools.length ? [] : this.burstGatedPoolsFor(step, effortTier);
272
289
  const refused = {
273
290
  ok: false,
274
291
  keepOnClaude: false,
275
- why: `no eligible pool (${route.why})`,
292
+ why: stillGated.length
293
+ ? `no eligible pool: every candidate is burst-gated (${WorkflowRuntime.describeBurstGate(stillGated)}) and the wait for the window expired`
294
+ : `no eligible pool (${route.why})`,
276
295
  pick: { pool: null },
277
296
  meta: { exitCode: null },
278
297
  };
@@ -656,7 +675,7 @@ export class WorkflowRuntime {
656
675
  });
657
676
  }
658
677
 
659
- preparePools(step, effortTier = step.effort ?? ({ analyze: 'high', build: 'medium', chore: 'low' }[step.lane ?? 'chore'])) {
678
+ preparePools(step, effortTier = step.effort ?? ({ analyze: 'high', build: 'medium', chore: 'low' }[step.lane ?? 'chore']), { ignoreBurstGate = false } = {}) {
660
679
  // Fresh eligible list per dispatch: enabled, not quarantined, and
661
680
  // not currently burst-gated (R8). Quarantine has been applied to
662
681
  // pool.quarantine by the live buildPools pass; if a previous
@@ -667,7 +686,7 @@ export class WorkflowRuntime {
667
686
  if (pool.quarantine && !isQuarantined(pool, now)) pool.quarantine = null;
668
687
  }
669
688
  return this.pools.filter(
670
- (p) => p.enabled !== false && !isQuarantined(p, now) && p.burstGate !== true &&
689
+ (p) => p.enabled !== false && !isQuarantined(p, now) && (ignoreBurstGate || p.burstGate !== true) &&
671
690
  (step.pool == null || p.name === step.pool) &&
672
691
  !(step.avoidPools ?? []).includes(p.name) &&
673
692
  (step.requiresCapabilities ?? []).every((capability) =>
@@ -682,6 +701,92 @@ export class WorkflowRuntime {
682
701
  }).filter((pool) => pool.modelPolicy.eligible);
683
702
  }
684
703
 
704
+ /** Pools that would serve this step if they were not burst-gated. */
705
+ burstGatedPoolsFor(step, effortTier) {
706
+ return this.preparePools(step, effortTier, { ignoreBurstGate: true }).filter((p) => p.burstGate === true);
707
+ }
708
+
709
+ static describeBurstGate(pools) {
710
+ return pools.map((p) => {
711
+ const w = p.meterSnapshot?.five_hour ?? {};
712
+ const used = Number.isFinite(w.utilization) ? `${Math.round(w.utilization)}% used` : 'usage unknown';
713
+ const resets = w.resets_at ? `resets ${new Date(w.resets_at).toISOString().replace(/\.\d{3}Z$/, 'Z')}` : 'reset time unknown';
714
+ return `${p.name} 5h window ${used}, ${resets}`;
715
+ }).join('; ');
716
+ }
717
+
718
+ /**
719
+ * If every pool that could serve `step` is burst-gated, wait for the gate
720
+ * to lift instead of failing the action: the provider window resets at a
721
+ * known time, the run is durable, and a failed run costs more than a late
722
+ * one. Re-reads the meters every quotaPollMs; gives up (so the caller fails
723
+ * with a clear reason) only after the latest known reset + grace, or after
724
+ * BURST_WAIT_UNKNOWN_RESET_MS when no reset time is known.
725
+ */
726
+ async awaitBurstRoom(step, effortTier, actionId = step.id) {
727
+ const gatedOnly = () => {
728
+ if (this.preparePools(step, effortTier).length) return null;
729
+ const gated = this.burstGatedPoolsFor(step, effortTier);
730
+ return gated.length ? gated : null;
731
+ };
732
+ let gated = gatedOnly();
733
+ if (!gated) return { waited: false };
734
+ const resetTimes = gated.map((p) => Date.parse(p.meterSnapshot?.five_hour?.resets_at ?? '')).filter(Number.isFinite);
735
+ const startedAt = Date.now();
736
+ const deadline = resetTimes.length
737
+ ? Math.max(...resetTimes) + this.quotaWaitGraceMs
738
+ : startedAt + this.quotaWaitUnknownResetMs;
739
+ const previousStage = this.state.stage;
740
+ this.state.stage = 'waiting_for_quota';
741
+ this.state.quotaWait = {
742
+ actionId,
743
+ since: new Date(startedAt).toISOString(),
744
+ until: new Date(deadline).toISOString(),
745
+ pools: gated.map((p) => ({
746
+ name: p.name,
747
+ fiveHourUsedPct: p.meterSnapshot?.five_hour?.utilization ?? null,
748
+ resetsAt: p.meterSnapshot?.five_hour?.resets_at ?? null,
749
+ })),
750
+ };
751
+ this.emit('dispatch.waiting_for_quota', { actionId, ...this.state.quotaWait, detail: WorkflowRuntime.describeBurstGate(gated) });
752
+ const cancelled = () => {
753
+ if (this.state.cancelRequested) return true;
754
+ try { return JSON.parse(readFileSync(join(this.runDir, 'state.json'), 'utf8')).cancelRequested === true; } catch { return false; }
755
+ };
756
+ let lifted = false;
757
+ while (Date.now() < deadline && !cancelled()) {
758
+ await new Promise((resolve) => setTimeout(resolve, Math.min(this.quotaPollMs, Math.max(1, deadline - Date.now()))));
759
+ for (const gatedView of gated) {
760
+ // preparePools hands out copies; the gate lives on the pool itself.
761
+ const pool = this.pools.find((p) => p.name === gatedView.name) ?? gatedView;
762
+ let reading = null;
763
+ try { reading = await this.readMeter(pool.name); } catch { reading = null; }
764
+ // Only a real provider snapshot may open or keep the gate; a pool
765
+ // without a meter reader ('none') leaves the gate as it was.
766
+ if (!reading?.snapshot) continue;
767
+ pool.burstGate = reading.burstGate === true;
768
+ pool.meterSnapshot = reading.snapshot;
769
+ if (reading.pacing) {
770
+ pool.usedPct = reading.pacing.usedPct ?? pool.usedPct;
771
+ pool.elapsedPct = reading.pacing.elapsedPct ?? pool.elapsedPct;
772
+ pool.pace = reading.pacing.surplus ?? pool.pace;
773
+ }
774
+ }
775
+ gated = gatedOnly();
776
+ if (!gated) { lifted = true; break; }
777
+ }
778
+ const waitedMs = Date.now() - startedAt;
779
+ delete this.state.quotaWait;
780
+ this.state.stage = previousStage;
781
+ if (lifted) {
782
+ this.emit('dispatch.quota_available', { actionId, waitedMs });
783
+ } else {
784
+ this.emit('dispatch.quota_wait_expired', { actionId, waitedMs, detail: gated ? WorkflowRuntime.describeBurstGate(gated) : null, cancelled: cancelled() });
785
+ }
786
+ this.persist();
787
+ return { waited: true, lifted, waitedMs, gated };
788
+ }
789
+
685
790
  appendDecision(step, poolName, verdict, paths, routing = null) {
686
791
  try {
687
792
  const coreState = loadState(this.bullswarmDir);
@@ -934,6 +1039,7 @@ export class WorkflowRuntime {
934
1039
 
935
1040
  async runDecision(step, scope, opts = {}) {
936
1041
  this.enforceRequiredInputs(step.id);
1042
+ await this.awaitBurstRoom(step, step.effort ?? 'high', step.id);
937
1043
  const deliveredSteering = deliverSteering(this.state, this.runDir);
938
1044
  for (const steering of deliveredSteering) {
939
1045
  this.emit('steering.delivered', {