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 +19 -0
- package/package.json +1 -1
- package/src/workflow/dashboard.js +49 -20
- package/src/workflow/runtime.js +70 -17
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
|
@@ -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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
|
667
|
-
|
|
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
|
|
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
|
|
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'
|
package/src/workflow/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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;
|