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 +17 -0
- package/package.json +1 -1
- package/skill/SKILL.md +6 -0
- package/src/workflow/runner.js +4 -0
- package/src/workflow/runtime.js +110 -4
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
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.
|
package/src/workflow/runner.js
CHANGED
|
@@ -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,
|
package/src/workflow/runtime.js
CHANGED
|
@@ -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:
|
|
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', {
|