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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.25.4",
3
+ "version": "0.25.5",
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": {
@@ -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. Humans can use bare `bullswarm strategy` to toggle providers
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
- const fiveHour = p.fiveHourUsedPct == null ? '' : ` 5h=${Math.round(p.fiveHourUsedPct * 10) / 10}%`;
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, { lane, picked: null, keepOnClaude: true, ok: null, why: route.why });
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, and quarantine/burst-gate status.',
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 doctor for a pass/fail readiness report instead of raw pool state.',
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 },