bullswarm 0.18.1 → 0.18.2

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,17 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.18.2 — truthful recovery timeline and verifier cleanup
4
+
5
+ - Dependency-blocked plan branches now appear as a single skipped phase in the
6
+ workflow timeline instead of several failed agents followed by a
7
+ contradictory completed phase.
8
+ - The Live panel ignores stale terminal agent records, and verifier retry
9
+ cleanup now removes the retrying agent as soon as the bounded retry ends.
10
+ - Verifier verdict parsing conservatively repairs an otherwise valid JSON
11
+ object truncated only by missing final closing brackets. Malformed content
12
+ still fails closed, while provider truncation no longer forces an expensive
13
+ planner recovery round.
14
+
3
15
  ## 0.18.1 — original-goal verification and denser timeline
4
16
 
5
17
  - Goal workflows now derive a durable requirement ledger from the original
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
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": {
@@ -201,15 +201,19 @@ const TERMINAL_ACTIONS = new Set([
201
201
  ]);
202
202
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
203
203
 
204
+ function isLiveAgent(agent) {
205
+ return !agent?.status || agent.status === 'running';
206
+ }
207
+
204
208
  function autonomousControlPlane(state) {
205
209
  const autonomous = state.intent?.autonomous === true || state.orchestration?.mode === 'autonomous';
206
210
  if (!autonomous) return { autonomous: false, actionId: null, attempts: [], active: null };
207
211
  const actionId = state.decisions?.find((decision) => decision.gateId)?.gateId ?? 'orchestrator';
208
212
  const attempts = (state.attempts ?? []).filter((attempt) => attempt.actionId === actionId);
209
- const active = Object.values(state.activeAgents ?? {}).find((agent) => agent.stepId === actionId) ?? null;
213
+ const active = Object.values(state.activeAgents ?? {}).find((agent) => agent.stepId === actionId && isLiveAgent(agent)) ?? null;
210
214
  const latestAttempt = attempts.at(-1) ?? null;
211
215
  const terminal = Boolean(state.finishedAt);
212
- const workerActive = Object.values(state.activeAgents ?? {}).some((agent) => agent.stepId !== actionId);
216
+ const workerActive = Object.values(state.activeAgents ?? {}).some((agent) => agent.stepId !== actionId && isLiveAgent(agent));
213
217
  const status = terminal
214
218
  ? state.status === 'completed' ? 'completed' : state.status ?? 'finished'
215
219
  : active ? 'planning'
@@ -233,7 +237,7 @@ function effectiveActionStatus(action, state) {
233
237
  // running even when a previous round recorded ok:false — a failed mark on
234
238
  // work that is still being retried misreports the run (user report 2026-08-29).
235
239
  const active = Object.values(state.activeAgents ?? {}).some((agent) =>
236
- agent.stepId === action.id || String(agent.stepId ?? '').startsWith(`${action.id}[`));
240
+ isLiveAgent(agent) && (agent.stepId === action.id || String(agent.stepId ?? '').startsWith(`${action.id}[`)));
237
241
  if (active || action.status === 'running') return 'running';
238
242
  const output = state.outputs?.[action.id];
239
243
  if (output?.ok === false) return 'failed_terminal';
@@ -645,6 +649,17 @@ function workflowTimelineLines(model, width) {
645
649
  const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
646
650
  if (!startedAt) continue;
647
651
  const label = phaseLabel(name, orchestrator);
652
+ const dependencyBlocked = actions.filter((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
653
+ if (!realStart && dependencyBlocked.length === actions.length) {
654
+ const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
655
+ if (finishedAt) {
656
+ add(finishedAt, [
657
+ timelineRow(finishedAt, `⊘ [Phase: ${label}] skipped`, `${actions.length} action${actions.length === 1 ? '' : 's'} not run`, width),
658
+ timelineDetail('Required earlier work did not pass; the planner chose a recovery path', width),
659
+ ], Number.MAX_SAFE_INTEGER, 'execution');
660
+ }
661
+ continue;
662
+ }
648
663
  add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width), Number.MAX_SAFE_INTEGER, 'execution');
649
664
  const finished = actions
650
665
  .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
@@ -654,17 +669,21 @@ function workflowTimelineLines(model, width) {
654
669
  const branch = terminalPhase ? '│ └─' : '│ ├─';
655
670
  const actionFinished = actionFinishedAt(state, action);
656
671
  const actionStarted = actionStartedAt(state, action);
672
+ const blocked = state.outputs?.[action.id]?.dependencyBlocked === true;
657
673
  add(actionFinished, timelineRow(
658
674
  actionFinished,
659
- `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
675
+ `${branch}${blocked ? '⊘' : statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
660
676
  actionStarted ? durationText(actionStarted, actionFinished) : '',
661
677
  width,
662
678
  ), Number.MAX_SAFE_INTEGER, 'execution');
663
679
  });
664
680
  if (finished.length === actions.length && actions.length) {
665
681
  const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
666
- const failed = actions.some((action) => String(effectiveActionStatus(action, state)).startsWith('failed'));
667
- add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : '✓'} [Phase: ${label}] completed`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
682
+ const blocked = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
683
+ const failed = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked !== true
684
+ && String(effectiveActionStatus(action, state)).startsWith('failed'));
685
+ const outcome = failed ? 'finished with failures' : blocked ? 'incomplete' : 'completed';
686
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : blocked ? '!' : '✓'} [Phase: ${label}] ${outcome}`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
668
687
  }
669
688
  }
670
689
 
@@ -725,7 +744,7 @@ function timelineControlEvent(event, width) {
725
744
  function workflowLiveLines(model, width, spinnerFrame) {
726
745
  const { state, orchestrator } = model;
727
746
  const activeWorkers = Object.values(state.activeAgents ?? {})
728
- .filter((agent) => agent.stepId !== orchestrator.actionId)
747
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent))
729
748
  .sort((a, b) => String(b.lastEventAt ?? b.lastActivityAt ?? '').localeCompare(String(a.lastEventAt ?? a.lastActivityAt ?? '')));
730
749
  const lines = [];
731
750
  let running = activeWorkers.length + (orchestrator.active ? 1 : 0);
@@ -900,7 +919,7 @@ function plannerDisplayStatus(model) {
900
919
  const { orchestrator, state } = model;
901
920
  if (state.finishedAt) return state.status === 'completed' ? 'Completed' : humanStatus(state.status);
902
921
  if (orchestrator.active) return 'Planning next actions';
903
- const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId);
922
+ const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
904
923
  if (workers.length) return 'Waiting for workers';
905
924
  if (orchestrator.status === 'reviewing evidence') return 'Reviewing evidence';
906
925
  return humanStatus(orchestrator.status);
@@ -932,7 +951,7 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
932
951
  const workerAttempts = (state.attempts ?? []).filter((attempt) => attempt.actionId !== orchestrator.actionId);
933
952
  const completedWorkers = workerAttempts.filter((attempt) => TERMINAL_ACTIONS.has(attempt.status)).length;
934
953
  const activeWorkers = Object.values(state.activeAgents ?? {})
935
- .filter((agent) => agent.stepId !== orchestrator.actionId);
954
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
936
955
  const nextActions = latestDecision?.actions?.map((action) => action.id).filter(Boolean) ?? [];
937
956
  const stateLabel = active
938
957
  ? 'Choosing the next smallest useful action'
@@ -75,6 +75,59 @@ export function enforceVerifyRequirementCoverage(parsed, requiredCoverage = [])
75
75
  return { parsed: normalized, concerns };
76
76
  }
77
77
 
78
+ export function parseVerifyJsonText(text) {
79
+ const source = String(text ?? '').trim();
80
+ const starts = [];
81
+ for (let index = 0; index < source.length; index++) {
82
+ if (source[index] === '{') starts.push(index);
83
+ }
84
+ let parseError = null;
85
+ for (const start of starts) {
86
+ const suffix = source.slice(start);
87
+ const candidates = [];
88
+ const lastClose = suffix.lastIndexOf('}');
89
+ if (lastClose > 0) candidates.push(suffix.slice(0, lastClose + 1));
90
+ const repaired = closeTruncatedJsonObject(suffix);
91
+ if (repaired && !candidates.includes(repaired)) candidates.push(repaired);
92
+ for (const candidate of candidates) {
93
+ try {
94
+ const parsed = JSON.parse(candidate);
95
+ if (parsed && typeof parsed === 'object'
96
+ && typeof parsed.ok === 'boolean'
97
+ && Array.isArray(parsed.concerns)
98
+ && typeof parsed.summary === 'string') {
99
+ return { parsed, parseError: null };
100
+ }
101
+ } catch (err) {
102
+ parseError = err.message;
103
+ }
104
+ }
105
+ }
106
+ return { parsed: null, parseError };
107
+ }
108
+
109
+ function closeTruncatedJsonObject(source) {
110
+ const stack = [];
111
+ let quoted = false;
112
+ let escaped = false;
113
+ for (const char of source) {
114
+ if (quoted) {
115
+ if (escaped) escaped = false;
116
+ else if (char === '\\') escaped = true;
117
+ else if (char === '"') quoted = false;
118
+ continue;
119
+ }
120
+ if (char === '"') quoted = true;
121
+ else if (char === '{') stack.push('}');
122
+ else if (char === '[') stack.push(']');
123
+ else if (char === '}' || char === ']') {
124
+ if (stack.pop() !== char) return null;
125
+ }
126
+ }
127
+ if (quoted || !stack.length) return null;
128
+ return `${source}${stack.reverse().join('')}`;
129
+ }
130
+
78
131
  export function plannerBudgetContext(budget = {}) {
79
132
  const dispatchesUsedBeforePlanner = Number(budget.dispatchesUsed ?? 0);
80
133
  const rawTarget = budget.dispatchTarget ?? budget.dispatchLimit;
@@ -1223,13 +1276,7 @@ export class WorkflowRuntime {
1223
1276
  const parseVerdictFile = (path) => {
1224
1277
  try {
1225
1278
  const out = readFileSync(path, 'utf8');
1226
- const start = out.indexOf('{');
1227
- const end = out.lastIndexOf('}');
1228
- if (start >= 0 && end > start) {
1229
- const j = JSON.parse(out.slice(start, end + 1));
1230
- if (j && typeof j === 'object') return { parsed: j, parseError: null };
1231
- }
1232
- return { parsed: null, parseError: null };
1279
+ return parseVerifyJsonText(out);
1233
1280
  } catch (err) {
1234
1281
  return { parsed: null, parseError: err.message };
1235
1282
  }
@@ -1243,16 +1290,22 @@ export class WorkflowRuntime {
1243
1290
  // same economics that give outputSchema its single retry.
1244
1291
  if (verdict.ok && !parsed) {
1245
1292
  this.emit('verify.verdict_retry', { actionId: step.id, why: parseError ?? 'no JSON object in verdict' });
1246
- const retryVerdict = await this.dispatch(step, [
1247
- taskText,
1248
- '',
1249
- `Your previous reply could not be used: ${parseError ?? 'it did not contain a parseable JSON object'}.`,
1250
- `Do the review again if needed, then RETURN ONLY the single JSON object ${requiredCoverage.length ? `{"ok":true,"concerns":[],"summary":"...","requirements":${coverageVerdictShape}}` : '{"ok": <true|false>, "concerns": [...], "summary": "..."}'} — no prose, no markdown fences, nothing before "{" or after "}".`,
1251
- ].join('\n'), targetDir, paths, {
1252
- escalate: this.state.settings.escalateOnFail !== false,
1253
- retryAttempts: 0,
1254
- phase: opts.phase,
1255
- });
1293
+ let retryVerdict;
1294
+ try {
1295
+ retryVerdict = await this.dispatch(step, [
1296
+ taskText,
1297
+ '',
1298
+ `Your previous reply could not be used: ${parseError ?? 'it did not contain a parseable JSON object'}.`,
1299
+ `Do the review again if needed, then RETURN ONLY the single JSON object ${requiredCoverage.length ? `{"ok":true,"concerns":[],"summary":"...","requirements":${coverageVerdictShape}}` : '{"ok": <true|false>, "concerns": [...], "summary": "..."}'} — no prose, no markdown fences, nothing before "{" or after "}".`,
1300
+ ].join('\n'), targetDir, paths, {
1301
+ escalate: this.state.settings.escalateOnFail !== false,
1302
+ retryAttempts: 0,
1303
+ phase: opts.phase,
1304
+ });
1305
+ } finally {
1306
+ delete this.state.activeAgents?.[step.id];
1307
+ this.persist();
1308
+ }
1256
1309
  if (retryVerdict.ok) {
1257
1310
  verdict = retryVerdict;
1258
1311
  finalPaths.taskFile = retryVerdict.taskFile ?? finalPaths.taskFile;