bullswarm 0.18.1 → 0.18.3

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,24 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.18.3 — mobile timeline polish
4
+
5
+ - Auto-follow now begins at a timestamped milestone instead of exposing an
6
+ orphaned detail line when a long timeline is clipped on a small terminal.
7
+ - Qualified terminal runs state the number of concerns and direct the user to
8
+ review them in the result envelope.
9
+
10
+ ## 0.18.2 — truthful recovery timeline and verifier cleanup
11
+
12
+ - Dependency-blocked plan branches now appear as a single skipped phase in the
13
+ workflow timeline instead of several failed agents followed by a
14
+ contradictory completed phase.
15
+ - The Live panel ignores stale terminal agent records, and verifier retry
16
+ cleanup now removes the retrying agent as soon as the bounded retry ends.
17
+ - Verifier verdict parsing conservatively repairs an otherwise valid JSON
18
+ object truncated only by missing final closing brackets. Malformed content
19
+ still fails closed, while provider truncation no longer forces an expensive
20
+ planner recovery round.
21
+
3
22
  ## 0.18.1 — original-goal verification and denser timeline
4
23
 
5
24
  - 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.3",
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';
@@ -555,13 +559,18 @@ function renderWorkflowOverviewPanel(model, width, height, spinnerFrame, timelin
555
559
  const timelineRows = Math.max(1, contentRows - liveRows - nextRows);
556
560
  const maxTimelineScroll = Math.max(0, timeline.lines.length - timelineRows);
557
561
  const scroll = clamp(timelineScroll, 0, maxTimelineScroll);
558
- const start = Math.max(0, timeline.lines.length - timelineRows - scroll);
559
- let visibleTimeline = timeline.lines.slice(start, start + timelineRows);
560
- if (start > 0 && visibleTimeline.length) {
561
- visibleTimeline[0] = dimText(`↑ ${start + 1} earlier timeline rows`, inner);
562
+ let start = Math.max(0, timeline.lines.length - timelineRows - scroll);
563
+ if (start > 0) {
564
+ while (start < timeline.lines.length && !/^\d{2}:\d{2}\s/.test(timeline.lines[start])) start += 1;
565
+ }
566
+ const historyRows = start > 0 ? Math.max(0, timelineRows - 1) : timelineRows;
567
+ let visibleTimeline = timeline.lines.slice(start, start + historyRows);
568
+ if (start > 0) {
569
+ visibleTimeline.unshift(dimText(`↑ ${start} earlier timeline rows`, inner));
562
570
  }
563
- if (start + timelineRows < timeline.lines.length && visibleTimeline.length) {
564
- visibleTimeline[visibleTimeline.length - 1] = dimText(`↓ ${timeline.lines.length - start - timelineRows + 1} newer timeline rows`, inner);
571
+ const end = start + historyRows;
572
+ if (end < timeline.lines.length && visibleTimeline.length) {
573
+ visibleTimeline[visibleTimeline.length - 1] = dimText(`↓ ${timeline.lines.length - end} newer timeline rows`, inner);
565
574
  }
566
575
  const visibleLive = live.lines.slice(0, liveRows);
567
576
  const visibleNext = next.slice(0, nextRows);
@@ -645,6 +654,17 @@ function workflowTimelineLines(model, width) {
645
654
  const startedAt = realStart ?? earliestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
646
655
  if (!startedAt) continue;
647
656
  const label = phaseLabel(name, orchestrator);
657
+ const dependencyBlocked = actions.filter((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
658
+ if (!realStart && dependencyBlocked.length === actions.length) {
659
+ const finishedAt = latestTimestamp(actions.map((action) => actionFinishedAt(state, action)));
660
+ if (finishedAt) {
661
+ add(finishedAt, [
662
+ timelineRow(finishedAt, `⊘ [Phase: ${label}] skipped`, `${actions.length} action${actions.length === 1 ? '' : 's'} not run`, width),
663
+ timelineDetail('Required earlier work did not pass; the planner chose a recovery path', width),
664
+ ], Number.MAX_SAFE_INTEGER, 'execution');
665
+ }
666
+ continue;
667
+ }
648
668
  add(startedAt, timelineRow(startedAt, `├─ [Phase: ${label}] ${realStart ? 'started' : 'blocked'}`, '', width), Number.MAX_SAFE_INTEGER, 'execution');
649
669
  const finished = actions
650
670
  .filter((action) => actionFinishedAt(state, action) && TERMINAL_ACTIONS.has(effectiveActionStatus(action, state)))
@@ -654,17 +674,21 @@ function workflowTimelineLines(model, width) {
654
674
  const branch = terminalPhase ? '│ └─' : '│ ├─';
655
675
  const actionFinished = actionFinishedAt(state, action);
656
676
  const actionStarted = actionStartedAt(state, action);
677
+ const blocked = state.outputs?.[action.id]?.dependencyBlocked === true;
657
678
  add(actionFinished, timelineRow(
658
679
  actionFinished,
659
- `${branch}${statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
680
+ `${branch}${blocked ? '⊘' : statusIcon(effectiveActionStatus(action, state))} [${label}] ${action.id}`,
660
681
  actionStarted ? durationText(actionStarted, actionFinished) : '',
661
682
  width,
662
683
  ), Number.MAX_SAFE_INTEGER, 'execution');
663
684
  });
664
685
  if (finished.length === actions.length && actions.length) {
665
686
  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');
687
+ const blocked = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked === true);
688
+ const failed = actions.some((action) => state.outputs?.[action.id]?.dependencyBlocked !== true
689
+ && String(effectiveActionStatus(action, state)).startsWith('failed'));
690
+ const outcome = failed ? 'finished with failures' : blocked ? 'incomplete' : 'completed';
691
+ add(finishedAt, timelineRow(finishedAt, `└─${failed ? '✗' : blocked ? '!' : '✓'} [Phase: ${label}] ${outcome}`, `${finished.length}/${actions.length}`, width), Number.MAX_SAFE_INTEGER, 'execution');
668
692
  }
669
693
  }
670
694
 
@@ -725,7 +749,7 @@ function timelineControlEvent(event, width) {
725
749
  function workflowLiveLines(model, width, spinnerFrame) {
726
750
  const { state, orchestrator } = model;
727
751
  const activeWorkers = Object.values(state.activeAgents ?? {})
728
- .filter((agent) => agent.stepId !== orchestrator.actionId)
752
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent))
729
753
  .sort((a, b) => String(b.lastEventAt ?? b.lastActivityAt ?? '').localeCompare(String(a.lastEventAt ?? a.lastActivityAt ?? '')));
730
754
  const lines = [];
731
755
  let running = activeWorkers.length + (orchestrator.active ? 1 : 0);
@@ -765,14 +789,16 @@ function workflowLiveLines(model, width, spinnerFrame) {
765
789
  lines.push('');
766
790
  }
767
791
  if (!lines.length) lines.push(state.finishedAt
768
- ? `${statusIcon(state.status)} No agents running · ${terminalWorkflowLabel(state.status)}`
792
+ ? `${statusIcon(state.status)} No agents running · ${terminalWorkflowLabel(state.status, state.outcome?.concerns?.length)}`
769
793
  : '⧖ Waiting for the next dispatch');
770
794
  return { lines, running, waiting };
771
795
  }
772
796
 
773
- function terminalWorkflowLabel(status) {
797
+ function terminalWorkflowLabel(status, concernCount = 0) {
774
798
  if (status === 'completed') return 'workflow finished';
775
- if (status === 'completed_with_concerns') return 'workflow finished with concerns';
799
+ if (status === 'completed_with_concerns') return concernCount
800
+ ? `workflow finished with ${concernCount} concern${concernCount === 1 ? '' : 's'}`
801
+ : 'workflow finished with concerns';
776
802
  if (status === 'blocked') return 'workflow stopped with blockers';
777
803
  if (status === 'failed') return 'workflow failed';
778
804
  if (status === 'cancelled') return 'workflow cancelled';
@@ -783,14 +809,17 @@ function terminalWorkflowLabel(status) {
783
809
  function workflowNextLines(model, width) {
784
810
  const { state, orchestrator } = model;
785
811
  if (state.finishedAt) {
786
- const next = state.status === 'completed' || state.status === 'completed_with_concerns'
812
+ const concernCount = state.outcome?.concerns?.length ?? 0;
813
+ const next = state.status === 'completed'
787
814
  ? 'result ready'
815
+ : state.status === 'completed_with_concerns'
816
+ ? concernCount ? `review ${concernCount} concern${concernCount === 1 ? '' : 's'} in result` : 'review concerns in result'
788
817
  : state.status === 'blocked' ? 'review blockers and partial work'
789
818
  : state.status === 'failed' ? 'inspect the failure before using partial work'
790
819
  : state.status === 'cancelled' ? 'review any partial work'
791
820
  : state.status === 'interrupted' ? 'resume the workflow or inspect partial work'
792
821
  : 'inspect the workflow result';
793
- const label = terminalWorkflowLabel(state.status);
822
+ const label = terminalWorkflowLabel(state.status, concernCount);
794
823
  return [truncate(`${statusIcon(state.status)} ${label[0].toUpperCase()}${label.slice(1)} · ${next}`, width)];
795
824
  }
796
825
  const ledger = state.actionLedger ?? [];
@@ -900,7 +929,7 @@ function plannerDisplayStatus(model) {
900
929
  const { orchestrator, state } = model;
901
930
  if (state.finishedAt) return state.status === 'completed' ? 'Completed' : humanStatus(state.status);
902
931
  if (orchestrator.active) return 'Planning next actions';
903
- const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId);
932
+ const workers = Object.values(state.activeAgents ?? {}).filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
904
933
  if (workers.length) return 'Waiting for workers';
905
934
  if (orchestrator.status === 'reviewing evidence') return 'Reviewing evidence';
906
935
  return humanStatus(orchestrator.status);
@@ -932,7 +961,7 @@ function orchestratorDetailLines(model, width, spinnerFrame, { verbose = false }
932
961
  const workerAttempts = (state.attempts ?? []).filter((attempt) => attempt.actionId !== orchestrator.actionId);
933
962
  const completedWorkers = workerAttempts.filter((attempt) => TERMINAL_ACTIONS.has(attempt.status)).length;
934
963
  const activeWorkers = Object.values(state.activeAgents ?? {})
935
- .filter((agent) => agent.stepId !== orchestrator.actionId);
964
+ .filter((agent) => agent.stepId !== orchestrator.actionId && isLiveAgent(agent));
936
965
  const nextActions = latestDecision?.actions?.map((action) => action.id).filter(Boolean) ?? [];
937
966
  const stateLabel = active
938
967
  ? '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;