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 +12 -0
- package/package.json +1 -1
- package/src/workflow/dashboard.js +28 -9
- package/src/workflow/runtime.js +70 -17
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
|
@@ -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
|
|
667
|
-
|
|
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'
|
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;
|