bullswarm 0.2.1 → 0.3.0

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.
@@ -37,7 +37,8 @@
37
37
  "chore"
38
38
  ],
39
39
  "flags": {
40
- "stealth": false
40
+ "stealth": false,
41
+ "isCaller": true
41
42
  },
42
43
  "timeoutSec": 900
43
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/route.js CHANGED
@@ -30,11 +30,28 @@ export function elapsedPct(meter, now = Date.now()) {
30
30
  return Math.min(100, ((now - start) / ms) * 100);
31
31
  }
32
32
 
33
- /** surplus = elapsed% − used%; higher = more quota about to expire. */
33
+ export const DEFAULT_COST_RANK = 5;
34
+
35
+ /** Coerce costRank safely: missing/NaN/non-number → DEFAULT_COST_RANK. */
36
+ export function costOf(pool) {
37
+ const c = Number(pool?.costRank);
38
+ return Number.isFinite(c) ? c : DEFAULT_COST_RANK;
39
+ }
40
+
41
+ /**
42
+ * Surplus = elapsed% − used%; higher = more quota about to expire.
43
+ *
44
+ * Shape tolerance (production bug fix): buildPools produces FLAT fields
45
+ * (pool.pace / pool.usedPct), while tests and legacy callers build
46
+ * pool.meter. Accept both. Never return NaN — a non-finite score would
47
+ * break the sort comparator's totality and make picks order-dependent.
48
+ */
34
49
  export function paceScore(pool, now = Date.now()) {
35
- const meter = pool.meter;
50
+ if (Number.isFinite(pool?.pace)) return pool.pace;
51
+ const meter = pool?.meter;
36
52
  if (!meter || meter.type === 'none' || meter.usedPct == null) return 0;
37
- return elapsedPct(meter, now) - meter.usedPct;
53
+ const s = elapsedPct(meter, now) - Number(meter.usedPct);
54
+ return Number.isFinite(s) ? s : 0;
38
55
  }
39
56
 
40
57
  export function isQuarantined(pool, now = Date.now()) {
@@ -44,7 +61,15 @@ export function isQuarantined(pool, now = Date.now()) {
44
61
  }
45
62
 
46
63
  export function isExhausted(pool) {
47
- return pool.meter?.usedPct != null && pool.meter.usedPct >= 100;
64
+ // Flat shape (buildPools) first, legacy meter shape second. A stale
65
+ // meterSource reading must not permanently exclude a pool: if the reading
66
+ // is stale-labeled and older than the window could explain, trust the pool
67
+ // may have reset — the next live poll will decide.
68
+ const used = pool?.usedPct ?? pool?.meter?.usedPct;
69
+ if (!Number.isFinite(used)) return false;
70
+ if (used < 100) return false;
71
+ if (pool.meterSource === 'stale') return false;
72
+ return true;
48
73
  }
49
74
 
50
75
  /**
@@ -112,18 +137,41 @@ export function pickPool(lane, pools, opts = {}) {
112
137
 
113
138
  let winnerEntry;
114
139
  if (incumbentEntry) {
115
- // R3+R4: challenger needs margin AND strictly lower costRank.
140
+ // R3+R4: challenger needs margin. The cost guard protects the incumbent
141
+ // ONLY while it is a reasonable steward of its quota: a distressed
142
+ // incumbent (deep negative surplus) forfeits cost protection, and
143
+ // equal-cost challengers may displace (strict < caused permanent
144
+ // lock-in between same-rank pools).
145
+ const INCUMBENT_DISTRESS = -20;
146
+ const incumbentDistressed =
147
+ incumbentEntry.pace <= INCUMBENT_DISTRESS || isExhausted(incumbentEntry.pool);
116
148
  const challenger = scored.find(
117
149
  (e) =>
118
150
  e !== incumbentEntry &&
119
151
  e.pace >= incumbentEntry.pace + INCUMBENCY_MARGIN &&
120
- (e.pool.costRank ?? 99) < (incumbentEntry.pool.costRank ?? 99),
152
+ (incumbentDistressed || costOf(e.pool) <= costOf(incumbentEntry.pool)),
121
153
  );
122
154
  winnerEntry = challenger ?? incumbentEntry;
123
155
  } else {
124
156
  winnerEntry = scored[0];
125
157
  }
126
158
 
159
+ // R5: the caller wins its lane only when no eligible delegate remains —
160
+ // or when the caller's own pool entry genuinely wins on merit. Dispatching
161
+ // the caller to itself as a subprocess is always wrong.
162
+ const isCaller =
163
+ winnerEntry.pool.isCaller === true ||
164
+ winnerEntry.pool.connector?.flags?.isCaller === true ||
165
+ (callerName && winnerEntry.pool.name === callerName);
166
+ if (isCaller) {
167
+ return {
168
+ pick: null,
169
+ keepOnClaude: true,
170
+ why: 'caller pool won the lane; keep work in-session',
171
+ candidates,
172
+ };
173
+ }
174
+
127
175
  return {
128
176
  pick: { pool: winnerEntry.pool.name, connector: winnerEntry.pool },
129
177
  keepOnClaude: false,
package/src/lib/state.js CHANGED
@@ -54,6 +54,11 @@ export function quarantinePool(state, poolName, reason, now = Date.now()) {
54
54
  const until = now + 10 * 60_000;
55
55
  state.pools[poolName] ??= {};
56
56
  state.pools[poolName].quarantine = { until, reason };
57
+ // A quarantined pool cannot hold incumbency: it isn't serving work, and
58
+ // keeping the flag would lock the lane against its return.
59
+ for (const [lane, name] of Object.entries(state.incumbents ?? {})) {
60
+ if (name === poolName) delete state.incumbents[lane];
61
+ }
57
62
  return until;
58
63
  }
59
64