bullswarm 0.25.4 → 0.25.5
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 +76 -0
- package/README.md +43 -0
- package/package.json +1 -1
- package/skill/references/operations.md +59 -1
- package/src/cli.js +109 -4
- package/src/help.js +34 -5
- package/src/lib/assignments.js +332 -0
- package/src/lib/forecast.js +111 -0
- package/src/lib/route.js +363 -67
- package/src/lib/spend.js +452 -0
- package/src/meters/framework.js +51 -0
- package/src/meters/registry.js +135 -2
- package/src/strategy-cli.js +16 -0
- package/src/strategy-dashboard.js +5 -2
- package/src/workflow/runtime.js +40 -1
- package/src/workflow/v2-dispatch.js +60 -3
- package/src/workflow/v2-runtime.js +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.5 — forecast-aware routing
|
|
4
|
+
|
|
5
|
+
- Bullswarm now knows what it is already running. Every dispatch registers the
|
|
6
|
+
work it starts in a small ledger on disk (`~/.bullswarm/assignments/`, one
|
|
7
|
+
atomically written file per assignment), and every process reads it: a
|
|
8
|
+
`bullswarm run` in one terminal, a V1 runtime and four concurrent V2 kernel
|
|
9
|
+
actions all see each other's agents instead of each assuming the pool is
|
|
10
|
+
idle. Records whose process is gone, or that are older than 12 hours, are
|
|
11
|
+
pruned on read, so a crash cannot leave phantom load behind. `bullswarm
|
|
12
|
+
assignments` lists what is in flight right now — pool, run, action, how long
|
|
13
|
+
it has been going and how much longer it is expected to take — and
|
|
14
|
+
`bullswarm pools` carries the same count as `inflight=<n>`.
|
|
15
|
+
|
|
16
|
+
- A spend model turns those records into percentage points. It measures how
|
|
17
|
+
fast a pool actually burns its 5-hour and weekly windows by pairing meter
|
|
18
|
+
readings with the worker-minutes dispatched between them, and how long an
|
|
19
|
+
assignment on a given lane and effort tier usually runs by taking the median
|
|
20
|
+
of real attempts from the decision log. Every number carries its basis —
|
|
21
|
+
`history` (measured), `bootstrap` (one window's usage so far), or the
|
|
22
|
+
documented `default` table — and the sample count behind it. A pool nobody
|
|
23
|
+
has measured reports `null`, never a plausible-looking guess.
|
|
24
|
+
|
|
25
|
+
- Routing now decides on the forecast instead of on the last reading. Each
|
|
26
|
+
pool's projection (reading + what its in-flight agents will still spend) gets
|
|
27
|
+
the expected consumption of the assignment being routed added on top, and the
|
|
28
|
+
existing thresholds apply to that number: a pool projected at or above 75% of
|
|
29
|
+
its 5-hour window drops to the near-limit tier while its reading is still
|
|
30
|
+
below the line, and one projected at or above 90% is dropped from selection
|
|
31
|
+
as forecast-gated. Nothing is gated on an unknown forecast, and if every
|
|
32
|
+
capable pool is gated, the least loaded of them is still picked — with the
|
|
33
|
+
reason saying exactly that — rather than the action being stranded.
|
|
34
|
+
|
|
35
|
+
- Parallel work now spreads instead of stacking. Within a tier, a pool's pace
|
|
36
|
+
surplus is reduced by the weekly quota its in-flight agents and this
|
|
37
|
+
assignment are expected to spend, and by at least a flat 3 surplus points
|
|
38
|
+
per in-flight agent (`DEFAULT_INFLIGHT_PENALTY_PCT`, `config.inflightPenaltyPct`
|
|
39
|
+
in state.json, `0` disables it). The floor matters: at measured weekly rates
|
|
40
|
+
a six-minute agent projects to under one point, which would leave a burst on
|
|
41
|
+
one pool. The charge is labeled `penalty` when the floor set it and by its
|
|
42
|
+
measured basis otherwise. Load also beats incumbency: an incumbent carrying
|
|
43
|
+
more in-flight agents than a challenger keeps neither its margin nor its cost
|
|
44
|
+
guard. Four actions launched within the same second land on
|
|
45
|
+
four different providers rather than all on the single most-behind one, and
|
|
46
|
+
the V2 kernel re-reads the ledger before every pick rather than only on its
|
|
47
|
+
throttled meter refresh, so actions launched seconds apart still see each
|
|
48
|
+
other.
|
|
49
|
+
|
|
50
|
+
- Everything that observes routing shows the new numbers. `bullswarm
|
|
51
|
+
assignments [--json]` is a new command listing the live ledger; `bullswarm
|
|
52
|
+
pools` gained an `inflight=<n>` column and prints its 5-hour cell as
|
|
53
|
+
`5h=<reading>%-><projected>%` when in-flight work is expected to move it,
|
|
54
|
+
with `spend`, `projectedFiveHourPct` and `projectedWeeklyPct` in `--json`;
|
|
55
|
+
`bullswarm run --dry-run` prints the forecast the pick was made on and, being
|
|
56
|
+
a preview, still registers nothing and writes no decision log; and the
|
|
57
|
+
strategy control center shows each provider's in-flight count next to its
|
|
58
|
+
usage. Live meter readings are now retained as a capped per-pool series at
|
|
59
|
+
`~/.bullswarm/meters/history/<pool>.jsonl`, because the snapshot cache keeps
|
|
60
|
+
only the newest reading and a rate needs two.
|
|
61
|
+
|
|
62
|
+
- A rate is only reported once the dispatch behind it is real: at least five
|
|
63
|
+
worker-minutes must be attributable to a window before its utilization
|
|
64
|
+
counts as percentage-points-per-minute. Without that floor a pool at 26% of
|
|
65
|
+
its 5-hour window with one six-second-old agent measures as 260% per minute
|
|
66
|
+
and forecasts every provider past the burst line, which is the failure this
|
|
67
|
+
model exists to prevent rather than cause.
|
|
68
|
+
|
|
69
|
+
- All of it is visible after the fact. The routing reason names the in-flight
|
|
70
|
+
counts and projections that moved the pick (`5h used 30% -> 41% projected, 2
|
|
71
|
+
in flight`, `skipped near 5h limit (projected): wati 76%`, `forecast-gated
|
|
72
|
+
at/above 90%: …`, `preferred over busier: …`), every candidate row carries
|
|
73
|
+
`pace`, `effectiveSurplus`, `inflight`, `projectedFiveHourPct`,
|
|
74
|
+
`forecastFiveHourPct`, `projectedWeeklyPct`, `ratePerMinute`,
|
|
75
|
+
`estimateSource` and `forecastGated`, and the decision log records the
|
|
76
|
+
forecast the pick was made on. Pools that carry no ledger or spend fields
|
|
77
|
+
route exactly as they did before.
|
|
78
|
+
|
|
3
79
|
## 0.25.4 — reasoning levels
|
|
4
80
|
|
|
5
81
|
- A connector now declares how its own CLI expresses a thinking level, and
|
package/README.md
CHANGED
|
@@ -227,6 +227,49 @@ name the utilization that decided the pick, and meters and quarantines are
|
|
|
227
227
|
re-read before each dispatch — and again, live, right after a usage limit —
|
|
228
228
|
so a long run never routes off the snapshot it launched with.
|
|
229
229
|
|
|
230
|
+
Those thresholds are applied to the FORECAST, not to the last reading. A meter
|
|
231
|
+
reading is already old when it arrives: agents dispatched seconds ago have
|
|
232
|
+
spent quota the provider has not reported yet, and the assignment being routed
|
|
233
|
+
will spend more. So each pool's projection — its reading plus the quota its
|
|
234
|
+
in-flight agents are still expected to burn — gets this candidate's own
|
|
235
|
+
expected consumption added, and the tiers apply to that number: a pool
|
|
236
|
+
projected at or above 75% drops to the near-limit tier even while its reading
|
|
237
|
+
is lower, and one projected at or above `BURST_BLOCK_PCT` (90) is left out of
|
|
238
|
+
selection entirely as forecast-gated. If every capable pool is forecast-gated,
|
|
239
|
+
routing still names the least loaded of them rather than stranding the action,
|
|
240
|
+
and says so in the reason. A pool with no measured rate forecasts nothing and
|
|
241
|
+
is never gated or deprioritized for a number nobody produced.
|
|
242
|
+
|
|
243
|
+
Within a tier, pools already carrying work yield to quieter pools of similar
|
|
244
|
+
pace: each pool's surplus is reduced by the weekly quota its in-flight agents
|
|
245
|
+
and this assignment are expected to spend, and by at least a flat 3 surplus
|
|
246
|
+
points per in-flight agent. That floor is what spreads work at real rates,
|
|
247
|
+
where a six-minute agent projects to well under one point; the charge is
|
|
248
|
+
labeled `penalty` when the floor set it and carries its measured basis
|
|
249
|
+
(`history`, `bootstrap`) when the projection was larger. Load also beats
|
|
250
|
+
incumbency: an incumbent carrying more in-flight agents than a challenger keeps
|
|
251
|
+
neither its 10-point margin nor its cost protection, so the quieter pool wins
|
|
252
|
+
as soon as its effective surplus is higher. A burst of parallel actions
|
|
253
|
+
therefore spreads across providers instead of stacking on the single
|
|
254
|
+
most-behind one.
|
|
255
|
+
`bullswarm pools` shows each pool's `inflight=<n>` count and its 5-hour column
|
|
256
|
+
as `5h=<reading>%-><projected>%` whenever in-flight work is expected to move
|
|
257
|
+
it, `bullswarm assignments` lists what those agents are, `bullswarm run
|
|
258
|
+
--dry-run` prints the forecast the pick was made on without registering
|
|
259
|
+
anything, and every candidate row carries `pace`, `effectiveSurplus`,
|
|
260
|
+
`inflight`, `projectedFiveHourPct`, `forecastFiveHourPct`, `ratePerMinute`,
|
|
261
|
+
`estimateSource` and `forecastGated`, so a surprising pick can be read back
|
|
262
|
+
number by number.
|
|
263
|
+
|
|
264
|
+
The rates come from real records: every live meter reading is retained as a
|
|
265
|
+
capped per-pool series (`~/.bullswarm/meters/history/<pool>.jsonl`) and paired
|
|
266
|
+
with the worker-minutes dispatched between readings. Until at least five
|
|
267
|
+
worker-minutes of dispatch are attributable to a window there is no rate at
|
|
268
|
+
all — `null`, not a ratio of percentage points to seconds — so a fresh machine
|
|
269
|
+
routes on pace and the flat penalty until it has measured something. The
|
|
270
|
+
penalty itself is `config.inflightPenaltyPct` in `~/.bullswarm/state.json`
|
|
271
|
+
(default 3; `0` turns the tie-breaker off).
|
|
272
|
+
|
|
230
273
|
Model exclusions are hard routing policy. An excluded model is removed from
|
|
231
274
|
recommendations and assignments, and Bullswarm pins a same-tier allowed model
|
|
232
275
|
through the connector-owned model flag whenever the provider default could be
|
package/package.json
CHANGED
|
@@ -161,7 +161,65 @@ explicitly approved model assignments and exclusions. A pool at or above 75%
|
|
|
161
161
|
of its 5-hour window is picked only when no eligible pool below that line
|
|
162
162
|
exists; `bullswarm pools` shows the reading as `5h=<n>%` with a
|
|
163
163
|
`NEAR-5H-LIMIT` label, and meters and quarantines are re-read before every
|
|
164
|
-
dispatch rather than frozen at launch.
|
|
164
|
+
dispatch rather than frozen at launch.
|
|
165
|
+
|
|
166
|
+
Those thresholds apply to the forecast, not to the reading: a pool's
|
|
167
|
+
projection (its reading plus what its in-flight agents will still spend) plus
|
|
168
|
+
the expected consumption of the assignment being routed. Diagnose a surprising
|
|
169
|
+
pick with the numbers, in this order:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
bullswarm assignments --json # what is running right now, in every process
|
|
173
|
+
bullswarm pools --json # inflight {count, minutes, records[]} per pool
|
|
174
|
+
bullswarm workflow runs show <id> --json # routing reason + candidates
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `bullswarm assignments` is the ledger itself — no meters, no network. Each
|
|
178
|
+
record names the pool, source (`run` / `workflow-v1` / `workflow-v2`), run
|
|
179
|
+
and action, `startedAt`, `elapsedMinutes`, `expectedMinutes` and
|
|
180
|
+
`remainingMinutes`. An empty list with work apparently running means the
|
|
181
|
+
dispatching process never registered it; a stale-looking entry is pruned on
|
|
182
|
+
the next read once its process is gone.
|
|
183
|
+
- `bullswarm pools` carries `inflight=<n>` next to each pool's `5h=<n>%`
|
|
184
|
+
reading; `--json` adds the full `inflight` block (`count`, elapsed
|
|
185
|
+
`minutes`, `remainingMinutes`, `unknownExpected`, `records[]`) and each
|
|
186
|
+
pool's `spend.fiveHour` / `spend.weekly` rates with
|
|
187
|
+
`projectedFiveHourPct` / `projectedWeeklyPct`.
|
|
188
|
+
- Candidate rows (in the decision log, `workflow runs show --json`, and
|
|
189
|
+
`run --json`) explain the pick number by number: `pace` is the raw quota
|
|
190
|
+
surplus, `effectiveSurplus` is that surplus after subtracting the work the
|
|
191
|
+
pool is already carrying, `inflight` is the agent count behind it,
|
|
192
|
+
`projectedFiveHourPct` is the reading plus in-flight spend,
|
|
193
|
+
`forecastFiveHourPct` adds this assignment, `ratePerMinute` is the measured
|
|
194
|
+
5-hour burn rate, and `estimateSource` says what the adjustment was based on
|
|
195
|
+
— `history` or `bootstrap` (the measured projection exceeded the floor),
|
|
196
|
+
`penalty` (the flat 3-points-per-in-flight-agent floor set the charge, either
|
|
197
|
+
because no rate is measured or because the projection was smaller), `none`
|
|
198
|
+
(nothing to charge). An incumbent carrying more in-flight agents than a
|
|
199
|
+
challenger loses its incumbency margin and cost guard, so `why` can name a
|
|
200
|
+
pricier pool when the incumbent is the one that is loaded.
|
|
201
|
+
- `forecastGated: true` means the pool was excluded because its forecast
|
|
202
|
+
reached 90%; `forecast.gated` on the result lists those names and
|
|
203
|
+
`forecast.candidateMinutes` is the duration the pick was made against. If
|
|
204
|
+
every capable pool is gated, the reason starts `every capable pool is
|
|
205
|
+
forecast-gated…` and the least-loaded one is used anyway.
|
|
206
|
+
- A `null` projection or `ratePerMinute` is a pool nobody has measured yet —
|
|
207
|
+
it is deliberately never gated or deprioritized for it, so unmeasured pools
|
|
208
|
+
can look "lucky" until the model has readings for them. A rate also stays
|
|
209
|
+
`null` until at least `MIN_RATE_MINUTES` (5) of dispatch is attributable to
|
|
210
|
+
the window: percentage points divided by six seconds of work is not a rate,
|
|
211
|
+
and forecasting on it would gate every pool the moment it took its first
|
|
212
|
+
assignment.
|
|
213
|
+
|
|
214
|
+
Live meter readings are retained as a capped per-pool series at
|
|
215
|
+
`~/.bullswarm/meters/history/<pool>.jsonl` (500 lines) — the snapshot cache
|
|
216
|
+
keeps only the newest reading, and a rate needs two. The log starts empty on
|
|
217
|
+
every machine, so rates read `bootstrap` (or `null`) until enough live
|
|
218
|
+
readings with dispatch between them accumulate. The flat per-in-flight-agent
|
|
219
|
+
penalty is `config.inflightPenaltyPct` in `~/.bullswarm/state.json` (default
|
|
220
|
+
3, `0` disables the tie-breaker).
|
|
221
|
+
|
|
222
|
+
Humans can use bare `bullswarm strategy` to toggle providers
|
|
165
223
|
and multi-select high/medium/low per model. Agents should consume the inventory
|
|
166
224
|
and apply validated changes with `strategy set-provider`, `strategy set-model`,
|
|
167
225
|
or one atomic `strategy configure --file <json> --yes`. Never weaken those
|
package/src/cli.js
CHANGED
|
@@ -27,6 +27,11 @@ import { helpForArgs, usageLine } from './help.js';
|
|
|
27
27
|
import { disabledModelsForPool, resolveDispatchModel, selectedModelsForTier } from './lib/strategy.js';
|
|
28
28
|
import { cmdDelegate } from './delegate.js';
|
|
29
29
|
import { createRunHeartbeat } from './lib/run-heartbeat.js';
|
|
30
|
+
import {
|
|
31
|
+
describeAssignment, expectedMinutesFromSpendModel, listAssignments,
|
|
32
|
+
registerAssignment, releaseAssignment, updateAssignment, withLedger,
|
|
33
|
+
} from './lib/assignments.js';
|
|
34
|
+
import { attachForecast, forecastRecord, inflightPenaltyFrom } from './lib/forecast.js';
|
|
30
35
|
|
|
31
36
|
export function getBullswarmDir() {
|
|
32
37
|
const h = process.env.BULLSWARM_HOME?.trim();
|
|
@@ -71,6 +76,10 @@ async function cmdPools(opts) {
|
|
|
71
76
|
console.error(`quarantine expired, returned to service: ${released.join(', ')}`);
|
|
72
77
|
}
|
|
73
78
|
saveState(getBullswarmDir(), state);
|
|
79
|
+
// Current cross-process load, from the shared ledger rather than this
|
|
80
|
+
// process's own memory: work another Bullswarm started still shows here —
|
|
81
|
+
// plus the spend rates that turn that load into a projected utilization.
|
|
82
|
+
attachForecast(pools, getBullswarmDir(), { now, decisionLog: state.decisionLog ?? [] });
|
|
74
83
|
if (opts.json) {
|
|
75
84
|
console.log(JSON.stringify({ pools }, null, 2));
|
|
76
85
|
return 0;
|
|
@@ -82,8 +91,16 @@ async function cmdPools(opts) {
|
|
|
82
91
|
: `used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}]`;
|
|
83
92
|
const burst = p.burstGate ? ' BURST-GATED' : '';
|
|
84
93
|
// 5h is a gate, never a pace (doctrine M3): show the reading and whether
|
|
85
|
-
// routing now deprioritizes this pool for it.
|
|
86
|
-
|
|
94
|
+
// routing now deprioritizes this pool for it. When in-flight work makes
|
|
95
|
+
// the projection differ from the reading, both are shown — routing decides
|
|
96
|
+
// on the right-hand number.
|
|
97
|
+
const readingPct = p.fiveHourUsedPct == null ? null : Math.round(p.fiveHourUsedPct * 10) / 10;
|
|
98
|
+
const projectedPct = p.projectedFiveHourPct == null
|
|
99
|
+
? null
|
|
100
|
+
: Math.round(p.projectedFiveHourPct * 10) / 10;
|
|
101
|
+
const fiveHour = readingPct == null
|
|
102
|
+
? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%`)
|
|
103
|
+
: ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}`;
|
|
87
104
|
const nearLimit = p.nearFiveHourLimit === true ? ' NEAR-5H-LIMIT' : '';
|
|
88
105
|
const status = !p.enabled
|
|
89
106
|
? 'disabled'
|
|
@@ -91,7 +108,40 @@ async function cmdPools(opts) {
|
|
|
91
108
|
? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
|
|
92
109
|
: `ready${burst}${nearLimit}`;
|
|
93
110
|
console.log(
|
|
94
|
-
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'}${fiveHour} ${status}`,
|
|
111
|
+
`${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- assignments --------------------------------------------------------------
|
|
118
|
+
// The in-flight ledger, read straight from disk: no meters, no network, no
|
|
119
|
+
// pool build — just what is running right now across every Bullswarm process.
|
|
120
|
+
|
|
121
|
+
function cmdAssignments(opts) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
const records = listAssignments(getBullswarmDir(), { now });
|
|
124
|
+
if (opts.json) {
|
|
125
|
+
console.log(JSON.stringify(
|
|
126
|
+
records.map((r) => ({ ...r, ...describeAssignment(r, now) })),
|
|
127
|
+
null,
|
|
128
|
+
2,
|
|
129
|
+
));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if (!records.length) {
|
|
133
|
+
console.log('no in-flight assignments');
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
for (const r of records) {
|
|
137
|
+
const view = describeAssignment(r, now);
|
|
138
|
+
const work = `${r.lane ?? '?'}/${r.effort ?? '?'}`;
|
|
139
|
+
const target = [r.runId, r.actionId].filter(Boolean).join('/') || '-';
|
|
140
|
+
const expected = view.expectedMinutes == null ? 'unknown' : `${view.expectedMinutes}m`;
|
|
141
|
+
console.log(
|
|
142
|
+
`${r.pool.padEnd(14)} ${work.padEnd(14)} ${(r.source ?? '-').padEnd(11)} ${target} `
|
|
143
|
+
+ `age=${view.elapsedMinutes ?? '?'}m expected=${expected} `
|
|
144
|
+
+ `worker=${r.workerPid ?? 'spawning'}`,
|
|
95
145
|
);
|
|
96
146
|
}
|
|
97
147
|
return 0;
|
|
@@ -175,6 +225,16 @@ async function cmdRun(opts) {
|
|
|
175
225
|
for (const p of pools) {
|
|
176
226
|
p.incumbent = state.incumbents?.[lane] === p.name;
|
|
177
227
|
}
|
|
228
|
+
// Route on the forecast, not on the reading: what every Bullswarm process
|
|
229
|
+
// has in flight right now, and how fast each pool burns its windows. Read
|
|
230
|
+
// before the eligible-pool copies are made so the fields survive the spread.
|
|
231
|
+
attachForecast(pools, getBullswarmDir(), { now, decisionLog: state.decisionLog ?? [] });
|
|
232
|
+
// The duration this assignment is booked for — the same number the ledger
|
|
233
|
+
// will publish for it (F3), so routing and every other process agree.
|
|
234
|
+
const expected = await expectedMinutesFromSpendModel(
|
|
235
|
+
{ lane, effort: effortTier },
|
|
236
|
+
{ decisionLog: state.decisionLog ?? [] },
|
|
237
|
+
);
|
|
178
238
|
|
|
179
239
|
// Burst gate (M3): a pool whose 5h window is >=90% used is excluded from
|
|
180
240
|
// dispatch entirely this run — it paces nothing, it's just out of burst room.
|
|
@@ -199,6 +259,8 @@ async function cmdRun(opts) {
|
|
|
199
259
|
now,
|
|
200
260
|
preferredPool: state.strategy?.assignments?.[effortTier]?.pool ?? null,
|
|
201
261
|
effortTier,
|
|
262
|
+
candidateMinutes: expected.expectedMinutes,
|
|
263
|
+
inflightPenaltyPct: inflightPenaltyFrom(state),
|
|
202
264
|
});
|
|
203
265
|
if (gated.length && route.pick) {
|
|
204
266
|
route.why += ` (burst-gated: ${gated.map((g) => g.name).join(', ')})`;
|
|
@@ -209,12 +271,16 @@ async function cmdRun(opts) {
|
|
|
209
271
|
// A preview never writes: the decision log records dispatches, not what
|
|
210
272
|
// an operator merely asked to see.
|
|
211
273
|
if (!dryRun) {
|
|
212
|
-
logDecision(state, {
|
|
274
|
+
logDecision(state, {
|
|
275
|
+
lane, picked: null, keepOnClaude: true, ok: null, why: route.why,
|
|
276
|
+
forecast: forecastRecord(route, null),
|
|
277
|
+
});
|
|
213
278
|
saveState(getBullswarmDir(), state);
|
|
214
279
|
}
|
|
215
280
|
emit({
|
|
216
281
|
ok: true, keepOnClaude: true, ...(dryRun ? { dryRun: true } : {}),
|
|
217
282
|
why: route.why, pick: { pool: null, command: null },
|
|
283
|
+
forecast: forecastRecord(route, null), candidates: route.candidates,
|
|
218
284
|
}, opts);
|
|
219
285
|
return 0;
|
|
220
286
|
}
|
|
@@ -255,11 +321,15 @@ async function cmdRun(opts) {
|
|
|
255
321
|
if (dryRun) {
|
|
256
322
|
// Preview through argvWithModel — the same builder runDelegate uses — so
|
|
257
323
|
// the printed command can never drift from the one that would be spawned.
|
|
324
|
+
// The forecast is reported exactly as a real dispatch would route on it;
|
|
325
|
+
// F1 keeps the preview a pure read — no ledger entry, no decision log.
|
|
258
326
|
emit({
|
|
259
327
|
ok: true,
|
|
260
328
|
dryRun: true,
|
|
261
329
|
keepOnClaude: false,
|
|
262
330
|
why: route.why,
|
|
331
|
+
forecast: forecastRecord(route, connector.name),
|
|
332
|
+
candidates: route.candidates,
|
|
263
333
|
pick: {
|
|
264
334
|
pool: connector.name,
|
|
265
335
|
model: selectedModel,
|
|
@@ -278,6 +348,17 @@ async function cmdRun(opts) {
|
|
|
278
348
|
|
|
279
349
|
const heartbeat = createRunHeartbeat({ intervalSec: heartbeatSec });
|
|
280
350
|
heartbeat.start();
|
|
351
|
+
// Registered the moment the pool is picked, before the worker exists, so no
|
|
352
|
+
// other process sees this pool as idle while the CLI is still spawning. The
|
|
353
|
+
// expectation is the one routing already booked this assignment for (F3).
|
|
354
|
+
const ledgerEntry = withLedger(() => registerAssignment(getBullswarmDir(), {
|
|
355
|
+
pool: connector.name,
|
|
356
|
+
model: selectedModel ?? connector.model ?? null,
|
|
357
|
+
lane: lane ?? null,
|
|
358
|
+
effort: effortTier ?? null,
|
|
359
|
+
source: 'run',
|
|
360
|
+
...expected,
|
|
361
|
+
}));
|
|
281
362
|
let verdict;
|
|
282
363
|
try {
|
|
283
364
|
verdict = await watchOnce(runtimeConnector, taskText, targetDir, paths, {
|
|
@@ -293,9 +374,13 @@ async function cmdRun(opts) {
|
|
|
293
374
|
bullswarmDir: getBullswarmDir(),
|
|
294
375
|
onActivity: (event) => heartbeat.activity(event),
|
|
295
376
|
onAgentEvent: () => heartbeat.event(),
|
|
377
|
+
onSpawn: (pid) => {
|
|
378
|
+
if (ledgerEntry) withLedger(() => updateAssignment(getBullswarmDir(), ledgerEntry.id, { workerPid: pid }));
|
|
379
|
+
},
|
|
296
380
|
});
|
|
297
381
|
} finally {
|
|
298
382
|
heartbeat.stop();
|
|
383
|
+
if (ledgerEntry) withLedger(() => releaseAssignment(getBullswarmDir(), ledgerEntry.id));
|
|
299
384
|
}
|
|
300
385
|
|
|
301
386
|
// Persist incumbency on success; quarantine hint on auth failure.
|
|
@@ -323,6 +408,11 @@ async function cmdRun(opts) {
|
|
|
323
408
|
reasoning,
|
|
324
409
|
usage: verdict.meta?.usage ?? null,
|
|
325
410
|
outFile: paths.outFile,
|
|
411
|
+
// The forecast this pick was made on — the numbers pickPool compared, so a
|
|
412
|
+
// later reader can replay the decision instead of re-deriving it. The full
|
|
413
|
+
// candidate list stays out of the log: 500 entries of it would bloat the
|
|
414
|
+
// state file the spend model has to read on every dispatch.
|
|
415
|
+
forecast: forecastRecord(route, connector.name),
|
|
326
416
|
});
|
|
327
417
|
saveState(getBullswarmDir(), state);
|
|
328
418
|
|
|
@@ -352,6 +442,19 @@ function emit(verdict, opts) {
|
|
|
352
442
|
if (Array.isArray(verdict.pick?.command) && verdict.dryRun) {
|
|
353
443
|
console.log(`command: ${verdict.pick.command.join(' ')}`);
|
|
354
444
|
}
|
|
445
|
+
// The forecast the pick was made on, so a preview explains itself without
|
|
446
|
+
// --json: what the pool is already carrying, where its 5h window is headed
|
|
447
|
+
// once this assignment runs, and what that estimate is based on.
|
|
448
|
+
if (verdict.forecast && verdict.dryRun) {
|
|
449
|
+
const f = verdict.forecast;
|
|
450
|
+
console.log(
|
|
451
|
+
`forecast: inflight=${f.inflight} `
|
|
452
|
+
+ `5h ${f.projectedFiveHourPct ?? '?'}%->${f.forecastFiveHourPct ?? '?'}% `
|
|
453
|
+
+ `expected=${f.expectedMinutes == null ? 'unknown' : `${f.expectedMinutes}m`} `
|
|
454
|
+
+ `rate=${f.ratePerMinute == null ? 'unmeasured' : `${f.ratePerMinute}%/min`} `
|
|
455
|
+
+ `basis=${f.estimateSource ?? 'none'}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
355
458
|
if (verdict.reasoning?.applied) {
|
|
356
459
|
const clamped = verdict.reasoning.clamped ? ', clamped' : '';
|
|
357
460
|
console.log(`reasoning: ${verdict.reasoning.applied} (${verdict.reasoning.source}${clamped})`);
|
|
@@ -584,6 +687,8 @@ export async function main(argv) {
|
|
|
584
687
|
return cmdHealth(opts);
|
|
585
688
|
case 'pools':
|
|
586
689
|
return cmdPools(opts);
|
|
690
|
+
case 'assignments':
|
|
691
|
+
return cmdAssignments(opts);
|
|
587
692
|
case 'doctor':
|
|
588
693
|
return cmdDoctor(opts);
|
|
589
694
|
case 'workflow':
|
package/src/help.js
CHANGED
|
@@ -66,7 +66,8 @@ const top = rich({
|
|
|
66
66
|
{ name: 'delegate', desc: 'classify any task, preview the execution shape, and route it to one agent or a workflow' },
|
|
67
67
|
{ name: 'run', desc: 'dispatch one bounded task' },
|
|
68
68
|
{ name: 'health', desc: 're-judge saved delegate outputs' },
|
|
69
|
-
{ name: 'pools', desc: 'show routing pools, meters, and quarantine state' },
|
|
69
|
+
{ name: 'pools', desc: 'show routing pools, meters, in-flight load, and quarantine state' },
|
|
70
|
+
{ name: 'assignments', desc: 'list the work in flight right now across every Bullswarm process' },
|
|
70
71
|
{ name: 'strategy', desc: 'discover models and manage tier assignments' },
|
|
71
72
|
{ name: 'doctor', desc: 'report installation readiness' },
|
|
72
73
|
{ name: 'workflow', desc: 'create, execute, observe, and audit workflows' },
|
|
@@ -272,17 +273,19 @@ const runText = rich({
|
|
|
272
273
|
{ flag: '--reasoning <low|medium|high|xhigh|max|default>', desc: "run-wide thinking-level override, clamped to what the picked pool's connector accepts; `default` passes nothing and lets the delegate CLI's own configuration decide", default: 'strategy reasoning setting for the effort tier, else the connector default' },
|
|
273
274
|
{ flag: '--timeout <seconds>', desc: 'hard wall-clock kill timer for the delegate process', default: 'none — the delegate is allowed to run to completion' },
|
|
274
275
|
{ flag: '--heartbeat <seconds>', desc: 'print one compact progress heartbeat to stderr per interval without streaming delegate output', default: 'off' },
|
|
275
|
-
{ flag: '--dry-run', desc: 'print the routing decision and the exact command that would be spawned (including the resolved reasoning flag) without spawning it or writing the decision log', default: 'off (dispatches for real)' },
|
|
276
|
+
{ flag: '--dry-run', desc: 'print the routing decision, the forecast it was made on, and the exact command that would be spawned (including the resolved reasoning flag) without spawning it, registering an in-flight assignment, or writing the decision log', default: 'off (dispatches for real)' },
|
|
276
277
|
{ flag: '--json', desc: 'print the machine-readable verdict document', default: 'human-readable summary line' },
|
|
277
278
|
],
|
|
278
279
|
safety: [
|
|
279
280
|
'spawns a real external coding-agent CLI process rooted at --add-dir (never with --dry-run)',
|
|
280
281
|
'writes ~/.bullswarm/state.json (decision log, pool incumbency) on completion; --dry-run writes neither',
|
|
282
|
+
'registers the picked pool in the shared in-flight ledger (~/.bullswarm/assignments/) for the life of the run and releases it when the attempt ends; --dry-run registers nothing',
|
|
281
283
|
'may quarantine a pool for a period after an authentication failure',
|
|
282
284
|
],
|
|
283
285
|
examples: [
|
|
284
286
|
{ cmd: 'bullswarm run --lane analyze --add-dir . "List every TODO comment in src/ with file:line"', note: 'routes one bounded analysis task and prints the verdict' },
|
|
285
287
|
{ cmd: 'bullswarm run --lane build --add-dir . --reasoning max --dry-run --json "Refactor the loader"', note: 'shows the exact argv, including the clamped reasoning flag, without dispatching' },
|
|
288
|
+
{ cmd: 'bullswarm run --lane build --add-dir . --dry-run "Refactor the loader"', note: 'prints a `forecast:` line — inflight count, projected 5h percent before and after this assignment, its expected minutes, the measured burn rate, and the basis of that estimate' },
|
|
286
289
|
],
|
|
287
290
|
next: 'bullswarm health to re-judge saved outputs, or bullswarm pools to check routing/quota state before the next run.',
|
|
288
291
|
});
|
|
@@ -347,18 +350,43 @@ const healthText = rich({
|
|
|
347
350
|
const poolsText = rich({
|
|
348
351
|
usage: 'bullswarm pools [--force] [--json]',
|
|
349
352
|
purpose: 'Show every configured pool: cost rank, lanes, live meter usage/elapsed percentage, '
|
|
350
|
-
+ 'pace surplus,
|
|
353
|
+
+ 'pace surplus, in-flight assignment count, projected 5-hour utilization, and '
|
|
354
|
+
+ 'quarantine/burst-gate status. The 5-hour column reads `5h=<reading>%` alone when '
|
|
355
|
+
+ 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
|
|
356
|
+
+ 'expected to push the window further; routing decides on the right-hand number.',
|
|
351
357
|
args: [],
|
|
352
358
|
options: [
|
|
353
359
|
{ flag: '--force', desc: 'bypass the meter cache and re-read live usage for every pool', default: 'off (cached meter readings reused within their TTL)' },
|
|
354
|
-
{ flag: '--json', desc: 'machine-readable pool array', default: 'human-readable aligned table' },
|
|
360
|
+
{ flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly} rates with their source and sample count, and projectedFiveHourPct / projectedWeeklyPct', default: 'human-readable aligned table' },
|
|
355
361
|
],
|
|
356
362
|
safety: [
|
|
357
363
|
'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
|
|
358
364
|
'always writes state.json after sweeping expired quarantines back into service, even in --json mode',
|
|
365
|
+
'reading the in-flight ledger prunes entries left behind by crashed processes (dead pids, or older than 12 hours)',
|
|
359
366
|
],
|
|
360
367
|
examples: [{ cmd: 'bullswarm pools --force' }],
|
|
361
|
-
next: 'bullswarm
|
|
368
|
+
next: 'bullswarm assignments to see which run and action each in-flight entry belongs to.',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const assignmentsText = rich({
|
|
372
|
+
usage: 'bullswarm assignments [--json]',
|
|
373
|
+
purpose: 'List the work in flight RIGHT NOW across every Bullswarm process — one line per '
|
|
374
|
+
+ 'live assignment with its pool, lane/effort, source, run/action, age in minutes, expected '
|
|
375
|
+
+ 'duration, and worker pid. This is the shared ledger `bullswarm pools` counts as '
|
|
376
|
+
+ 'inflight=<n>, so a run started by another terminal or kernel appears here too.',
|
|
377
|
+
args: [],
|
|
378
|
+
options: [
|
|
379
|
+
{ flag: '--json', desc: 'machine-readable array of live assignment records', default: 'one human-readable line per assignment' },
|
|
380
|
+
],
|
|
381
|
+
safety: [
|
|
382
|
+
'reads ~/.bullswarm/assignments/ only — no meters, no network, no pool build',
|
|
383
|
+
'prunes entries whose kernel and worker processes are both gone, and entries older than 12 hours, so a crashed process leaves no phantom load',
|
|
384
|
+
],
|
|
385
|
+
examples: [
|
|
386
|
+
{ cmd: 'bullswarm assignments' },
|
|
387
|
+
{ cmd: 'bullswarm assignments --json', note: 'each record carries pool, source, runId, actionId, kernelPid, workerPid, startedAt, elapsedMinutes, expectedMinutes, remainingMinutes' },
|
|
388
|
+
],
|
|
389
|
+
next: 'bullswarm pools to see that same load next to each pool\'s meter and pace surplus.',
|
|
362
390
|
});
|
|
363
391
|
|
|
364
392
|
const doctorText = rich({
|
|
@@ -1522,6 +1550,7 @@ const HELP = {
|
|
|
1522
1550
|
delegate: { _text: delegateText },
|
|
1523
1551
|
health: { _text: healthText },
|
|
1524
1552
|
pools: { _text: poolsText },
|
|
1553
|
+
assignments: { _text: assignmentsText },
|
|
1525
1554
|
doctor: { _text: doctorText },
|
|
1526
1555
|
version: { _text: versionText },
|
|
1527
1556
|
release: { _text: releaseText },
|