bullswarm 0.8.0 → 0.9.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.
- package/CHANGELOG.md +12 -0
- package/README.md +31 -10
- package/package.json +1 -1
- package/skill/SKILL.md +11 -4
- package/src/cli.js +19 -4
- package/src/lib/state.js +1 -0
- package/src/lib/strategy.js +31 -3
- package/src/setup.js +26 -1
- package/src/strategy-cli.js +118 -2
- package/src/workflow/cli.js +44 -7
- package/src/workflow/dashboard.js +13 -3
- package/src/workflow/draft-cli.js +4 -3
- package/src/workflow/goal.js +12 -2
- package/src/workflow/runner.js +56 -6
- package/src/workflow/runtime.js +25 -4
- package/src/workflow/short-id.js +93 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# bullswarm changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — resilient dynamic workflow routing
|
|
4
|
+
|
|
5
|
+
- Added cooperative `SIGTERM`/`SIGINT` handling, durable `interrupted` states,
|
|
6
|
+
dead/stale owner reconciliation, and clean resume after interruption.
|
|
7
|
+
- Added capability-context filtering for model recommendations plus an explicit
|
|
8
|
+
`strategy apply --yes` approval gate and TTL-based automatic refresh.
|
|
9
|
+
- Added setup-time worktree policy and strategy-autopilot choices.
|
|
10
|
+
- Added per-attempt routing reasons/candidate surplus to durable events, state,
|
|
11
|
+
decision logs, and the printable workflow tree.
|
|
12
|
+
- Added top-level `runs` and `--version` aliases, complete `workflow goal`
|
|
13
|
+
budget help, and correct phase/terminal display for completed runs.
|
|
14
|
+
|
|
3
15
|
## 0.8.0 — autonomous goals, model strategy, and auditable usage
|
|
4
16
|
|
|
5
17
|
- Added `workflow goal` for bounded observe-plan-execute loops without an
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ npm install -g bullswarm # or: node bin/bullswarm.js directly from a checkout
|
|
|
31
31
|
bullswarm # first run: interactive setup wizard
|
|
32
32
|
bullswarm setup # re-run or repair
|
|
33
33
|
bullswarm pools # meter state, pace position, quarantine status
|
|
34
|
-
bullswarm strategy refresh #
|
|
34
|
+
bullswarm strategy refresh --apply --yes # approve capability-aware tier autopilot
|
|
35
35
|
bullswarm run --lane analyze --add-dir ~/some-repo --task-file /tmp/t.md --json
|
|
36
36
|
bullswarm workflow goal "Fix the failing tests and verify the change" --cwd ~/some-repo
|
|
37
37
|
bullswarm health # re-judge saved outputs; catch gate failures
|
|
@@ -57,18 +57,25 @@ connector-declared, dated pricing/benchmark metadata with live quota surplus:
|
|
|
57
57
|
```bash
|
|
58
58
|
bullswarm strategy refresh
|
|
59
59
|
bullswarm strategy show --json
|
|
60
|
+
bullswarm strategy apply --yes --refresh-hours 24
|
|
61
|
+
bullswarm strategy auto status
|
|
60
62
|
bullswarm strategy set-subscription command-code \
|
|
61
63
|
--plan GOAT --monthly-usd 10 --included-usd 70 --quota-window monthly
|
|
62
64
|
bullswarm strategy assign high --pool claude-code --model claude-opus-4-6
|
|
63
65
|
bullswarm run --effort high --lane analyze --task-file /tmp/task.md --json
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
Interactive setup asks whether to enable strategy autopilot; non-interactive
|
|
69
|
+
setup requires the explicit `setup --yes --strategy` flag. Recommendations are
|
|
70
|
+
context-filtered before ranking: high requires analysis plus workflow-planning,
|
|
71
|
+
medium requires build/edit capabilities, and low targets bounded chores. An
|
|
72
|
+
approved policy refreshes stale discovery before later runs and re-applies the
|
|
73
|
+
best eligible models on its configured interval. Disable it with
|
|
74
|
+
`strategy auto off --yes`. Discovery commands, model argument syntax, pricing,
|
|
75
|
+
and benchmark declarations remain connector-owned. Unknown license value,
|
|
76
|
+
prices, and benchmarks stay `null` rather than being guessed. An assignment is
|
|
77
|
+
only a preference: quarantine, exhaustion, burst gates, and capability checks
|
|
78
|
+
still win.
|
|
72
79
|
|
|
73
80
|
Every run and workflow attempt reports its selected agent/model and estimated
|
|
74
81
|
usage. When a delegate does not expose counters, Bullswarm labels its UTF-8
|
|
@@ -94,8 +101,9 @@ bullswarm workflow goal \
|
|
|
94
101
|
--cwd ~/some-repo --detach --json
|
|
95
102
|
```
|
|
96
103
|
|
|
97
|
-
Bullswarm
|
|
98
|
-
|
|
104
|
+
Bullswarm first honors an approved high-tier provider/model assignment when it
|
|
105
|
+
remains eligible, otherwise it selects an eligible `workflow-planning`
|
|
106
|
+
orchestrator by live quota surplus. The orchestrator observes durable evidence, proposes bounded actions,
|
|
99
107
|
and decides when another expansion or verification is necessary. Bullswarm
|
|
100
108
|
validates the proposal, owns agent/process selection, routes workers, and calls
|
|
101
109
|
the orchestrator again until completion, cancellation, failure, approval, or a
|
|
@@ -121,7 +129,9 @@ bullswarm workflow goal --resume <shortId> --json
|
|
|
121
129
|
`--orchestrator <pool>` exists for controlled testing; ordinary use should
|
|
122
130
|
leave selection on `auto`. Hard limits can be adjusted with `--max-agents`,
|
|
123
131
|
`--max-expansion-rounds`, `--max-actions`, `--max-items-per-expansion`, and
|
|
124
|
-
`--max-workflow-seconds`.
|
|
132
|
+
`--max-workflow-seconds`. Interactive setup also records a worktree-isolation
|
|
133
|
+
preference (`agent-decides`, `off`, or `required`); Bullswarm communicates that
|
|
134
|
+
policy to the orchestrator without imposing repository topology itself.
|
|
125
135
|
|
|
126
136
|
## Building a workflow from the shell
|
|
127
137
|
|
|
@@ -164,6 +174,7 @@ bullswarm workflow runs --historical # only historical
|
|
|
164
174
|
bullswarm workflow runs --name audit-code # filter by workflow
|
|
165
175
|
bullswarm workflow runs --limit 20 # cap the result count
|
|
166
176
|
bullswarm workflow runs show <shortId> # state + report + summary
|
|
177
|
+
bullswarm runs show <shortId> # top-level shorthand
|
|
167
178
|
bullswarm workflow runs delete <shortId> --yes # remove the run dir
|
|
168
179
|
|
|
169
180
|
# Resume by shortId — runs the same logic as the full runId
|
|
@@ -197,6 +208,16 @@ bullswarm workflow approval approve --json <id> # then resume the run
|
|
|
197
208
|
|
|
198
209
|
Cancellation is persisted as `cancelling`, terminates an active child process,
|
|
199
210
|
records its termination signal and latency evidence, then commits `cancelled`.
|
|
211
|
+
`SIGTERM` and `SIGINT` use the same cooperative child termination path but
|
|
212
|
+
commit a distinct resumable `interrupted` state. On every workflow command,
|
|
213
|
+
active states with a dead/stale owner are automatically reconciled to
|
|
214
|
+
`interrupted` instead of remaining falsely `running`.
|
|
215
|
+
|
|
216
|
+
Each attempt records the phase/action, selected pool and model, effort tier,
|
|
217
|
+
routing reason, all eligible candidates with quota surplus, timestamps,
|
|
218
|
+
artifact paths, outcome, and reported-or-estimated token/cost/quota usage.
|
|
219
|
+
`workflow tui <id>` renders this breakdown for completed runs as well as live
|
|
220
|
+
ones; `workflow tui --json <id>` exposes the durable audit document.
|
|
200
221
|
|
|
201
222
|
### Adaptive workflows
|
|
202
223
|
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -95,7 +95,9 @@ The detached runner does not depend on the initiating CLI remaining alive.
|
|
|
95
95
|
Resume a process-interrupted run from its persisted definition with
|
|
96
96
|
`bullswarm workflow goal --resume <shortId> --json`. Leave orchestrator
|
|
97
97
|
selection automatic in normal use; `--orchestrator=<pool>` is for controlled
|
|
98
|
-
provider QA.
|
|
98
|
+
provider QA. `SIGTERM`/`SIGINT` cooperatively terminate the active delegate and
|
|
99
|
+
persist `interrupted`; later workflow commands also reconcile dead or stale
|
|
100
|
+
owners into that explicit resumable state.
|
|
99
101
|
|
|
100
102
|
## Multi-step shape (`bullswarm workflow draft ...`)
|
|
101
103
|
|
|
@@ -187,11 +189,15 @@ among capable pools. For strategic model selection, first run:
|
|
|
187
189
|
```bash
|
|
188
190
|
bullswarm strategy refresh
|
|
189
191
|
bullswarm strategy show --json
|
|
192
|
+
bullswarm strategy apply --yes --refresh-hours 24
|
|
193
|
+
bullswarm strategy auto status
|
|
190
194
|
bullswarm strategy assign high --pool <pool> --model <model>
|
|
191
195
|
```
|
|
192
196
|
|
|
193
|
-
Connector-declared discovery, dated benchmark/pricing evidence,
|
|
194
|
-
produce high/medium/low suggestions;
|
|
197
|
+
Connector-declared discovery, dated benchmark/pricing evidence, live quota,
|
|
198
|
+
and tier-specific capability requirements produce high/medium/low suggestions;
|
|
199
|
+
unknown evidence remains null. `apply --yes` is the explicit approval gate: it
|
|
200
|
+
persists assignments and enables TTL-based discovery/re-application. A step's
|
|
195
201
|
`effort` or a lane default (`analyze=high`, `build=medium`, `chore=low`) can use
|
|
196
202
|
an assignment, but it never bypasses capability, quarantine, exhaustion, or
|
|
197
203
|
burst-gate safety. Each attempt records the chosen agent/model and labeled
|
|
@@ -200,7 +206,8 @@ token, cost, and normalized-quota estimates in the workflow tree.
|
|
|
200
206
|
Run state also exposes the versioned plan, action ledger, aggregate usage, every attempt,
|
|
201
207
|
planner decisions and reasons, budgets, `currentPhase`, `currentStep`, and
|
|
202
208
|
`activeAgents` in `workflow tui --json <shortId>`. Each attempt includes its
|
|
203
|
-
pool, selected model, effort tier,
|
|
209
|
+
pool, selected model, effort tier, routing reason and eligible candidates,
|
|
210
|
+
usage/cost estimate, status, task/output artifacts, timings, failure
|
|
204
211
|
reason, and child-process termination evidence. `workflow tui` displays the
|
|
205
212
|
same information interactively. `workflow events` supports replay after a
|
|
206
213
|
monotonic sequence cursor.
|
package/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { judgeContent } from './lib/verify.js';
|
|
|
15
15
|
import { getVersion } from './lib/version.js';
|
|
16
16
|
import { release } from './lib/release.js';
|
|
17
17
|
import { cmdWorkflow } from './workflow/cli.js';
|
|
18
|
-
import { cmdStrategy } from './strategy-cli.js';
|
|
18
|
+
import { cmdStrategy, maybeRefreshStrategy } from './strategy-cli.js';
|
|
19
19
|
|
|
20
20
|
export function getBullswarmDir() {
|
|
21
21
|
const h = process.env.BULLSWARM_HOME?.trim();
|
|
@@ -33,7 +33,7 @@ function parseArgs(argv) {
|
|
|
33
33
|
if (argv[i].startsWith('--')) {
|
|
34
34
|
const key = argv[i].slice(2);
|
|
35
35
|
if (key === 'json') args.json = true;
|
|
36
|
-
else if (i + 1 < argv.length) args[key] = argv[++i];
|
|
36
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) args[key] = argv[++i];
|
|
37
37
|
else args[key] = true;
|
|
38
38
|
} else rest.push(argv[i]);
|
|
39
39
|
}
|
|
@@ -97,6 +97,11 @@ async function cmdRun(opts) {
|
|
|
97
97
|
return 1;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Only an explicitly approved strategy policy may change assignments.
|
|
101
|
+
// Once approved, refresh capability-aware recommendations on its TTL.
|
|
102
|
+
await maybeRefreshStrategy(getBullswarmDir());
|
|
103
|
+
state = loadState(getBullswarmDir());
|
|
104
|
+
|
|
100
105
|
sweepQuarantines(state, now);
|
|
101
106
|
|
|
102
107
|
const { pools } = await buildPoolsLive(getBullswarmDir(), now, {
|
|
@@ -281,11 +286,18 @@ async function cmdSetup(opts) {
|
|
|
281
286
|
// defaults and never prompts.
|
|
282
287
|
if (opts.yes || !process.stdin.isTTY) {
|
|
283
288
|
const r = autoSetup(getBullswarmDir(), { reason: opts.yes ? 'flag' : 'non-tty' });
|
|
284
|
-
|
|
289
|
+
let strategy = null;
|
|
290
|
+
if (opts.yes && opts.strategy) {
|
|
291
|
+
const { refreshStrategy, applyStrategyRecommendations } = await import('./strategy-cli.js');
|
|
292
|
+
const report = await refreshStrategy(getBullswarmDir());
|
|
293
|
+
strategy = applyStrategyRecommendations(getBullswarmDir(), report);
|
|
294
|
+
}
|
|
295
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, mode: 'auto', ...r, strategy }, null, 2));
|
|
285
296
|
else {
|
|
286
297
|
console.log(`setup complete (${r.reason}): enabled ${r.enabledPools.join(', ')}`);
|
|
287
298
|
if (r.repaired.length) console.log(`repaired connector files: ${r.repaired.join(', ')}`);
|
|
288
299
|
console.log(`model strategy: ${r.strategyCommand} (discovers models and refreshes tier suggestions)`);
|
|
300
|
+
if (strategy) console.log(`strategy autopilot: applied ${Object.keys(strategy.applied).join(', ')} tiers; refresh every ${strategy.policy.refreshHours}h`);
|
|
289
301
|
}
|
|
290
302
|
return 0;
|
|
291
303
|
}
|
|
@@ -394,16 +406,19 @@ export async function main(argv) {
|
|
|
394
406
|
return cmdDoctor(opts);
|
|
395
407
|
case 'workflow':
|
|
396
408
|
return cmdWorkflow(rest);
|
|
409
|
+
case 'runs':
|
|
410
|
+
return cmdWorkflow(['runs', ...rest]);
|
|
397
411
|
case 'strategy':
|
|
398
412
|
return cmdStrategy(rest, { bullswarmDir: getBullswarmDir() });
|
|
399
413
|
case 'version':
|
|
414
|
+
case '--version':
|
|
400
415
|
console.log(getVersion());
|
|
401
416
|
return 0;
|
|
402
417
|
case 'release':
|
|
403
418
|
return cmdRelease(opts);
|
|
404
419
|
default:
|
|
405
420
|
console.error(
|
|
406
|
-
`unknown verb "${verb}". try: setup | run | health | pools | strategy | doctor | workflow | version | release`,
|
|
421
|
+
`unknown verb "${verb}". try: setup | run | health | pools | strategy | doctor | workflow | runs | version | release`,
|
|
407
422
|
);
|
|
408
423
|
return 2;
|
|
409
424
|
}
|
package/src/lib/state.js
CHANGED
package/src/lib/strategy.js
CHANGED
|
@@ -129,14 +129,41 @@ function candidateScore(candidate, tier) {
|
|
|
129
129
|
return freeBonus * 200 - costRank * 20 + quality * 10 + pace;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
export const TIER_CONTEXTS = {
|
|
133
|
+
high: {
|
|
134
|
+
lane: 'analyze',
|
|
135
|
+
capabilities: ['strong-analysis', 'workflow-planning'],
|
|
136
|
+
description: 'analysis and autonomous orchestration',
|
|
137
|
+
},
|
|
138
|
+
medium: {
|
|
139
|
+
lane: 'build',
|
|
140
|
+
capabilities: ['code-reading', 'file-editing'],
|
|
141
|
+
description: 'implementation and verification',
|
|
142
|
+
},
|
|
143
|
+
low: {
|
|
144
|
+
lane: 'chore',
|
|
145
|
+
capabilities: [],
|
|
146
|
+
description: 'bounded chores and low-cost work',
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function supportsContext(pool, context) {
|
|
151
|
+
const lanes = pool.lanes ?? pool.connector?.lanes ?? [];
|
|
152
|
+
const capabilities = pool.capabilities ?? pool.connector?.capabilities ?? [];
|
|
153
|
+
return lanes.includes(context.lane)
|
|
154
|
+
&& context.capabilities.every((capability) => capabilities.includes(capability));
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
export function buildStrategy({ connectors, pools, state, discoveries }) {
|
|
133
158
|
const subscriptions = pools.map((pool) => subscriptionView(pool, state));
|
|
134
159
|
const tiers = ['high', 'medium', 'low'];
|
|
135
160
|
const suggestions = {};
|
|
136
161
|
for (const tier of tiers) {
|
|
162
|
+
const context = TIER_CONTEXTS[tier];
|
|
137
163
|
const candidates = [];
|
|
138
164
|
for (const pool of pools) {
|
|
139
165
|
if (pool.enabled === false || pool.quarantine || pool.burstGate) continue;
|
|
166
|
+
if (!supportsContext(pool, context)) continue;
|
|
140
167
|
const discovery = discoveries[pool.name];
|
|
141
168
|
for (const model of discovery?.models ?? []) {
|
|
142
169
|
if (model.tier !== tier) continue;
|
|
@@ -149,6 +176,7 @@ export function buildStrategy({ connectors, pools, state, discoveries }) {
|
|
|
149
176
|
suggestions[tier] = {
|
|
150
177
|
assignment: configured,
|
|
151
178
|
recommended: candidates[0] ? { pool: candidates[0].pool.name, model: candidates[0].model.id } : null,
|
|
179
|
+
requirements: context,
|
|
152
180
|
candidates: candidates.slice(0, 8).map((candidate) => ({
|
|
153
181
|
pool: candidate.pool.name,
|
|
154
182
|
model: candidate.model.id,
|
|
@@ -161,10 +189,10 @@ export function buildStrategy({ connectors, pools, state, discoveries }) {
|
|
|
161
189
|
score: Math.round(candidate.score * 10) / 10,
|
|
162
190
|
})),
|
|
163
191
|
basis: tier === 'high'
|
|
164
|
-
? 'dated benchmark score
|
|
192
|
+
? 'analysis/workflow-planning capability, then dated benchmark score (quality rank fallback), live quota surplus, and cost rank'
|
|
165
193
|
: tier === 'medium'
|
|
166
|
-
? 'balanced dated benchmark or quality rank, live quota surplus, and cost rank'
|
|
167
|
-
: 'free/low-cost first, then dated benchmark or quality rank and live quota surplus',
|
|
194
|
+
? 'build/editing capability, then balanced dated benchmark or quality rank, live quota surplus, and cost rank'
|
|
195
|
+
: 'chore capability and free/low-cost first, then dated benchmark or quality rank and live quota surplus',
|
|
168
196
|
};
|
|
169
197
|
}
|
|
170
198
|
return {
|
package/src/setup.js
CHANGED
|
@@ -357,7 +357,32 @@ export async function runWizard(bullswarmDir, opts = {}) {
|
|
|
357
357
|
console.log(`\nWrote ${bullswarmDir}/state.json and routing.json`);
|
|
358
358
|
if (repaired.length) console.log(`Repaired connector files: ${repaired.join(', ')}`);
|
|
359
359
|
|
|
360
|
-
// 5.
|
|
360
|
+
// 5. Optional execution style. Agent-decides is the neutral default: it
|
|
361
|
+
// communicates preference without forcing a repository/worktree topology.
|
|
362
|
+
const worktreeAnswer = (
|
|
363
|
+
await rl.question('worktree isolation [agent/off/required] (default agent): ')
|
|
364
|
+
).trim().toLowerCase();
|
|
365
|
+
state.config ??= {};
|
|
366
|
+
state.config.worktreeIsolation = worktreeAnswer === 'required'
|
|
367
|
+
? 'required' : worktreeAnswer === 'off' ? 'off' : 'agent-decides';
|
|
368
|
+
saveState(bullswarmDir, state);
|
|
369
|
+
console.log(` worktree isolation: ${state.config.worktreeIsolation}`);
|
|
370
|
+
|
|
371
|
+
// Strategy changes actual provider/model routing, so discovery plus daily
|
|
372
|
+
// auto-application always requires an explicit setup answer.
|
|
373
|
+
const strategyAnswer = (
|
|
374
|
+
await rl.question('discover models and enable capability-aware daily strategy autopilot? [y/N] ')
|
|
375
|
+
).trim().toLowerCase();
|
|
376
|
+
if (strategyAnswer === 'y' || strategyAnswer === 'yes') {
|
|
377
|
+
const { refreshStrategy, applyStrategyRecommendations } = await import('./strategy-cli.js');
|
|
378
|
+
const report = await refreshStrategy(bullswarmDir);
|
|
379
|
+
const applied = applyStrategyRecommendations(bullswarmDir, report);
|
|
380
|
+
console.log(` strategy tiers applied: ${Object.entries(applied.applied).map(([tier, value]) => `${tier}=${value.pool}/${value.model}`).join(', ')}`);
|
|
381
|
+
} else {
|
|
382
|
+
console.log(' strategy autopilot: off (enable later with bullswarm strategy apply --yes)');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 6. Integration blocks — diff + approval
|
|
361
386
|
for (const [label, path] of [
|
|
362
387
|
['CLAUDE.md', join(process.env.HOME ?? '', '.claude', 'CLAUDE.md')],
|
|
363
388
|
['AGENTS.md', join(process.cwd(), 'AGENTS.md')],
|
package/src/strategy-cli.js
CHANGED
|
@@ -24,6 +24,12 @@ function numberOrNull(value, label) {
|
|
|
24
24
|
return n;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function refreshHoursValue(value) {
|
|
28
|
+
const hours = Number(value ?? 24);
|
|
29
|
+
if (!Number.isFinite(hours) || hours <= 0) throw new Error('refresh-hours must be a positive number');
|
|
30
|
+
return hours;
|
|
31
|
+
}
|
|
32
|
+
|
|
27
33
|
function render(report) {
|
|
28
34
|
const lines = [`bullswarm strategy · ${report.capturedAt}`, '', 'subscriptions:'];
|
|
29
35
|
for (const sub of report.subscriptions) {
|
|
@@ -42,6 +48,21 @@ function render(report) {
|
|
|
42
48
|
return lines.join('\n');
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
function strategyUsage() {
|
|
52
|
+
return `usage: bullswarm strategy <command> [options]
|
|
53
|
+
|
|
54
|
+
commands:
|
|
55
|
+
refresh [--json] discover models and recommend tiers
|
|
56
|
+
refresh --apply --yes [--refresh-hours] discover, approve, and enable refresh
|
|
57
|
+
apply --yes [--refresh-hours <n>] approve the last recommendations
|
|
58
|
+
auto status inspect the approved refresh policy
|
|
59
|
+
auto off --yes disable automatic re-application
|
|
60
|
+
show [--json] show the last strategy report
|
|
61
|
+
assign <high|medium|low> --pool --model set one explicit preference
|
|
62
|
+
clear-assignment <tier> remove one preference
|
|
63
|
+
set-subscription <pool> [value flags] record user-known plan economics`;
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
export async function refreshStrategy(bullswarmDir, { executor, getReadings = getAllMeterReadings } = {}) {
|
|
46
67
|
const state = loadState(bullswarmDir);
|
|
47
68
|
const connectors = loadConnectors(bullswarmDir);
|
|
@@ -57,13 +78,82 @@ export async function refreshStrategy(bullswarmDir, { executor, getReadings = ge
|
|
|
57
78
|
return report;
|
|
58
79
|
}
|
|
59
80
|
|
|
81
|
+
export function applyStrategyRecommendations(bullswarmDir, report, {
|
|
82
|
+
refreshHours = 24, enableAutoRefresh = true,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const approvedRefreshHours = enableAutoRefresh ? refreshHoursValue(refreshHours) : null;
|
|
85
|
+
const state = loadState(bullswarmDir);
|
|
86
|
+
state.strategy ??= {};
|
|
87
|
+
state.strategy.assignments ??= {};
|
|
88
|
+
const applied = {};
|
|
89
|
+
for (const tier of ['high', 'medium', 'low']) {
|
|
90
|
+
const recommended = report?.suggestions?.[tier]?.recommended ?? null;
|
|
91
|
+
if (!recommended) continue;
|
|
92
|
+
state.strategy.assignments[tier] = { ...recommended };
|
|
93
|
+
applied[tier] = { ...recommended };
|
|
94
|
+
}
|
|
95
|
+
state.strategy.policy = {
|
|
96
|
+
...(state.strategy.policy ?? {}),
|
|
97
|
+
autoApplyRecommendations: enableAutoRefresh,
|
|
98
|
+
refreshHours: approvedRefreshHours,
|
|
99
|
+
approvedAt: new Date().toISOString(),
|
|
100
|
+
source: 'explicit-user-approval',
|
|
101
|
+
};
|
|
102
|
+
state.strategy.lastAppliedAt = new Date().toISOString();
|
|
103
|
+
// Keep the persisted report aligned with the assignments just applied.
|
|
104
|
+
if (report) {
|
|
105
|
+
for (const tier of ['high', 'medium', 'low']) {
|
|
106
|
+
if (report.suggestions?.[tier]) report.suggestions[tier].assignment = applied[tier] ?? null;
|
|
107
|
+
}
|
|
108
|
+
state.strategy.lastReport = report;
|
|
109
|
+
}
|
|
110
|
+
saveState(bullswarmDir, state);
|
|
111
|
+
return { applied, policy: state.strategy.policy };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function maybeRefreshStrategy(bullswarmDir, opts = {}) {
|
|
115
|
+
const state = loadState(bullswarmDir);
|
|
116
|
+
const policy = state.strategy?.policy ?? {};
|
|
117
|
+
if (policy.autoApplyRecommendations !== true) return null;
|
|
118
|
+
try {
|
|
119
|
+
const hours = refreshHoursValue(policy.refreshHours);
|
|
120
|
+
const captured = Date.parse(state.strategy?.lastRefreshedAt ?? '');
|
|
121
|
+
const stale = !Number.isFinite(captured) || (Date.now() - captured) >= hours * 3600_000;
|
|
122
|
+
if (!stale) return null;
|
|
123
|
+
const report = await refreshStrategy(bullswarmDir, opts);
|
|
124
|
+
return {
|
|
125
|
+
report,
|
|
126
|
+
...applyStrategyRecommendations(bullswarmDir, report, { refreshHours: hours }),
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Discovery is advisory and must never turn an otherwise routable run into
|
|
130
|
+
// an outage. Keep the last approved assignments and expose the failure.
|
|
131
|
+
const failed = loadState(bullswarmDir);
|
|
132
|
+
failed.strategy ??= {};
|
|
133
|
+
failed.strategy.policy ??= policy;
|
|
134
|
+
failed.strategy.policy.lastRefreshErrorAt = new Date().toISOString();
|
|
135
|
+
failed.strategy.policy.lastRefreshError = err.message;
|
|
136
|
+
saveState(bullswarmDir, failed);
|
|
137
|
+
return { error: err.message, retainedAssignments: failed.strategy.assignments ?? {} };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
60
141
|
export async function cmdStrategy(args, { bullswarmDir }) {
|
|
61
142
|
const [sub = 'show', ...rest] = args;
|
|
62
143
|
const opts = parseFlags(rest);
|
|
63
144
|
try {
|
|
145
|
+
if (sub === 'help' || sub === '--help' || opts.help) {
|
|
146
|
+
console.log(strategyUsage());
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
64
149
|
if (sub === 'refresh' || sub === 'recommend') {
|
|
150
|
+
if (opts.apply && opts.yes !== true) throw new Error('--apply changes routing; pass --yes to approve');
|
|
65
151
|
const report = await refreshStrategy(bullswarmDir);
|
|
66
|
-
|
|
152
|
+
const applied = opts.apply
|
|
153
|
+
? applyStrategyRecommendations(bullswarmDir, report, {
|
|
154
|
+
refreshHours: refreshHoursValue(opts['refresh-hours']),
|
|
155
|
+
}) : null;
|
|
156
|
+
console.log(opts.json ? JSON.stringify(applied ? { report, ...applied } : report, null, 2) : render(report));
|
|
67
157
|
return 0;
|
|
68
158
|
}
|
|
69
159
|
if (sub === 'show') {
|
|
@@ -110,6 +200,32 @@ export async function cmdStrategy(args, { bullswarmDir }) {
|
|
|
110
200
|
console.log(JSON.stringify({ action: 'tier-assigned', tier, assignment: state.strategy.assignments[tier] }, null, 2));
|
|
111
201
|
return 0;
|
|
112
202
|
}
|
|
203
|
+
if (sub === 'apply') {
|
|
204
|
+
if (opts.yes !== true) throw new Error('strategy apply changes routing; pass --yes to approve');
|
|
205
|
+
const state = loadState(bullswarmDir);
|
|
206
|
+
const report = state.strategy?.lastReport ?? await refreshStrategy(bullswarmDir);
|
|
207
|
+
const result = applyStrategyRecommendations(bullswarmDir, report, {
|
|
208
|
+
refreshHours: refreshHoursValue(opts['refresh-hours']),
|
|
209
|
+
});
|
|
210
|
+
console.log(JSON.stringify({ action: 'strategy-applied', ...result }, null, 2));
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
if (sub === 'auto') {
|
|
214
|
+
const mode = opts.rest[0] ?? 'status';
|
|
215
|
+
const state = loadState(bullswarmDir);
|
|
216
|
+
state.strategy ??= {};
|
|
217
|
+
state.strategy.policy ??= {};
|
|
218
|
+
if (mode === 'off') {
|
|
219
|
+
if (opts.yes !== true) throw new Error('strategy auto off changes routing policy; pass --yes to approve');
|
|
220
|
+
state.strategy.policy.autoApplyRecommendations = false;
|
|
221
|
+
state.strategy.policy.disabledAt = new Date().toISOString();
|
|
222
|
+
saveState(bullswarmDir, state);
|
|
223
|
+
} else if (mode !== 'status') {
|
|
224
|
+
throw new Error('usage: bullswarm strategy auto <status|off> [--yes]');
|
|
225
|
+
}
|
|
226
|
+
console.log(JSON.stringify({ action: 'strategy-auto', policy: state.strategy.policy }, null, 2));
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
113
229
|
if (sub === 'clear-assignment') {
|
|
114
230
|
const tier = opts.rest[0];
|
|
115
231
|
if (!['high', 'medium', 'low'].includes(tier)) throw new Error('usage: bullswarm strategy clear-assignment <high|medium|low>');
|
|
@@ -120,7 +236,7 @@ export async function cmdStrategy(args, { bullswarmDir }) {
|
|
|
120
236
|
console.log(JSON.stringify({ action: 'tier-assignment-cleared', tier }, null, 2));
|
|
121
237
|
return 0;
|
|
122
238
|
}
|
|
123
|
-
throw new Error(
|
|
239
|
+
throw new Error(strategyUsage());
|
|
124
240
|
} catch (err) {
|
|
125
241
|
console.error(`✗ ${err.message}`);
|
|
126
242
|
return 1;
|
package/src/workflow/cli.js
CHANGED
|
@@ -9,15 +9,17 @@ import { homedir } from 'node:os';
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { loadWorkflow, runWorkflow, newRunId } from './runner.js';
|
|
11
11
|
import { validateWorkflow, WorkflowValidationError } from './validate.js';
|
|
12
|
-
import { buildPoolsLive } from '../lib/config.js';
|
|
12
|
+
import { buildPools, buildPoolsLive } from '../lib/config.js';
|
|
13
13
|
import { getAllMeterReadings } from '../meters/registry.js';
|
|
14
14
|
import { WorkflowTui } from './tui.js';
|
|
15
15
|
import { cmdDraft } from './draft-cli.js';
|
|
16
16
|
import { cmdRuns } from './runs-cli.js';
|
|
17
|
-
import { resolveRunId } from './short-id.js';
|
|
17
|
+
import { resolveRunId, reconcileInterruptedRuns } from './short-id.js';
|
|
18
18
|
import { runDashboard, dashboardJson, actionJson, decideApproval } from './dashboard.js';
|
|
19
19
|
import { readEvents } from './events.js';
|
|
20
20
|
import { buildGoalWorkflow } from './goal.js';
|
|
21
|
+
import { maybeRefreshStrategy } from '../strategy-cli.js';
|
|
22
|
+
import { loadState } from '../lib/state.js';
|
|
21
23
|
|
|
22
24
|
// BULLSWARM_DIR is read on every call so that changes to the
|
|
23
25
|
// BULLSWARM_HOME env var (e.g. set per-test) are honored, not
|
|
@@ -76,6 +78,7 @@ function discover() {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
export async function cmdWorkflow(args) {
|
|
81
|
+
reconcileInterruptedRuns(BULLSWARM_DIR());
|
|
79
82
|
const [sub, ...rest] = args;
|
|
80
83
|
const opts = parseFlags(rest);
|
|
81
84
|
|
|
@@ -151,6 +154,24 @@ function goalSettings(opts) {
|
|
|
151
154
|
.map(([flag, setting]) => [setting, opts[flag]]));
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
function goalUsage() {
|
|
158
|
+
return `usage: bullswarm workflow goal "<goal>" [options]
|
|
159
|
+
|
|
160
|
+
options:
|
|
161
|
+
--cwd <dir> target working directory
|
|
162
|
+
--detach launch independently and return observation commands
|
|
163
|
+
--json emit one machine-readable launch/report document
|
|
164
|
+
--orchestrator <pool|auto> pin only for controlled use; default capability routing
|
|
165
|
+
--max-agents <n> hard dispatch ceiling (default 30)
|
|
166
|
+
--max-expansion-rounds <n> planner expansion ceiling (default 8)
|
|
167
|
+
--max-actions <n> durable action ceiling (default 40)
|
|
168
|
+
--max-items-per-expansion <n> fanout item ceiling (default 8)
|
|
169
|
+
--max-workflow-seconds <n> wall-clock ceiling (default 3600)
|
|
170
|
+
--concurrency <n> concurrent dispatch ceiling, max 16 (default 3)
|
|
171
|
+
--retry-attempts <0..3> same-pool retry bound (default 1)
|
|
172
|
+
--resume <shortId|runId> resume durable unfinished work`;
|
|
173
|
+
}
|
|
174
|
+
|
|
154
175
|
async function executeGoalDocument({ doc, pools, opts, runId, resumeRunId }) {
|
|
155
176
|
const tui = new WorkflowTui({ quiet: opts.quiet, json: opts.json });
|
|
156
177
|
const result = await runWorkflow({
|
|
@@ -242,6 +263,10 @@ async function launchDetachedGoal(doc, opts) {
|
|
|
242
263
|
}
|
|
243
264
|
|
|
244
265
|
async function wfGoal(opts) {
|
|
266
|
+
if (opts.help) {
|
|
267
|
+
console.log(goalUsage());
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
245
270
|
const { names, pools } = await livePoolNames();
|
|
246
271
|
let doc;
|
|
247
272
|
let resumeRunId = null;
|
|
@@ -278,7 +303,7 @@ async function wfGoal(opts) {
|
|
|
278
303
|
} else {
|
|
279
304
|
const goal = opts.rest.join(' ').trim();
|
|
280
305
|
if (!goal) {
|
|
281
|
-
console.error(
|
|
306
|
+
console.error(goalUsage());
|
|
282
307
|
return 2;
|
|
283
308
|
}
|
|
284
309
|
const orchestrator = opts.orchestrator && opts.orchestrator !== 'auto'
|
|
@@ -289,6 +314,7 @@ async function wfGoal(opts) {
|
|
|
289
314
|
cwd: opts.cwd ?? process.cwd(),
|
|
290
315
|
orchestrator,
|
|
291
316
|
settings: goalSettings(opts),
|
|
317
|
+
worktreeIsolation: loadState(BULLSWARM_DIR()).config?.worktreeIsolation ?? 'agent-decides',
|
|
292
318
|
});
|
|
293
319
|
} catch (err) {
|
|
294
320
|
console.error(`✗ invalid goal options: ${err.message}`);
|
|
@@ -325,6 +351,7 @@ async function wfGoal(opts) {
|
|
|
325
351
|
|
|
326
352
|
async function wfCapabilities(opts) {
|
|
327
353
|
const { pools } = await livePoolNames();
|
|
354
|
+
const coreState = loadState(BULLSWARM_DIR());
|
|
328
355
|
const result = {
|
|
329
356
|
lanes: ['analyze', 'build', 'chore'],
|
|
330
357
|
stepTypes: ['run', 'fanout', 'verify', 'decide'],
|
|
@@ -342,12 +369,20 @@ async function wfCapabilities(opts) {
|
|
|
342
369
|
maxRetryAttempts: 3,
|
|
343
370
|
resume: true,
|
|
344
371
|
cooperativeCancellation: true,
|
|
372
|
+
cooperativeSignalInterruption: true,
|
|
373
|
+
staleOwnerReconciliation: true,
|
|
345
374
|
adversarialVerification: true,
|
|
346
375
|
},
|
|
347
376
|
routing: {
|
|
348
377
|
automatic: true,
|
|
349
|
-
selection: 'highest time-adjusted quota surplus among lane-
|
|
350
|
-
modelSelection: 'connector-
|
|
378
|
+
selection: 'approved effort-tier assignment when eligible; otherwise highest time-adjusted quota surplus among lane/capability-eligible, enabled, non-quarantined, non-burst-gated pools',
|
|
379
|
+
modelSelection: 'connector-declared discovery and model flag; approved capability-aware strategy assignments may select a model',
|
|
380
|
+
strategyPolicy: coreState.strategy?.policy ?? null,
|
|
381
|
+
assignments: coreState.strategy?.assignments ?? {},
|
|
382
|
+
},
|
|
383
|
+
worktreeIsolation: {
|
|
384
|
+
policy: coreState.config?.worktreeIsolation ?? 'agent-decides',
|
|
385
|
+
enforcement: 'optional agent execution-style preference; Bullswarm does not impose repository topology',
|
|
351
386
|
},
|
|
352
387
|
pools: pools.map((p) => ({
|
|
353
388
|
name: p.name,
|
|
@@ -526,12 +561,14 @@ async function wfValidate(opts) {
|
|
|
526
561
|
|
|
527
562
|
async function livePoolNames() {
|
|
528
563
|
try {
|
|
564
|
+
await maybeRefreshStrategy(BULLSWARM_DIR());
|
|
529
565
|
const { pools } = await buildPoolsLive(BULLSWARM_DIR(), Date.now(), {
|
|
530
566
|
getReadings: getAllMeterReadings,
|
|
531
567
|
});
|
|
532
568
|
return { names: pools.map((p) => p.name), pools };
|
|
533
|
-
} catch {
|
|
534
|
-
|
|
569
|
+
} catch (err) {
|
|
570
|
+
const { pools } = buildPools(BULLSWARM_DIR(), Date.now());
|
|
571
|
+
return { names: pools.map((p) => p.name), pools, meterWarning: err.message };
|
|
535
572
|
}
|
|
536
573
|
}
|
|
537
574
|
|
|
@@ -29,7 +29,7 @@ export function requestCancel(bullswarmDir, token) {
|
|
|
29
29
|
const statePath = join(resolved.runDir, 'state.json');
|
|
30
30
|
if (!existsSync(statePath)) throw new Error(`run "${token}" has no state.json`);
|
|
31
31
|
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
32
|
-
if (state.finishedAt || ['completed', 'failed', 'cancelled'].includes(state.status)) {
|
|
32
|
+
if (state.finishedAt || ['completed', 'failed', 'cancelled', 'interrupted', 'budget_exhausted'].includes(state.status)) {
|
|
33
33
|
return { ...resolved, state, alreadyFinished: true };
|
|
34
34
|
}
|
|
35
35
|
state.cancelRequested = true;
|
|
@@ -88,13 +88,19 @@ export function renderDashboard({ rows, selected = 0, message = null } = {}) {
|
|
|
88
88
|
export function renderDetails(row, { interactive = true } = {}) {
|
|
89
89
|
const state = row?.state ?? {};
|
|
90
90
|
const phases = state._doc?.phases ?? [];
|
|
91
|
+
const displayedPhase = state.currentPhase?.name
|
|
92
|
+
?? state.steps?.at(-1)?.phase
|
|
93
|
+
?? state.stage
|
|
94
|
+
?? 'starting';
|
|
95
|
+
const displayedCurrent = state.currentStep?.id
|
|
96
|
+
?? (state.finishedAt ? `terminal:${state.status ?? state.stage ?? 'finished'}` : '—');
|
|
91
97
|
const lines = [
|
|
92
98
|
`${ESC}2J${ESC}H`,
|
|
93
99
|
` bullswarm · ${state.workflow ?? '?'} · ${row?.shortId ?? row?.runId ?? '?'}`,
|
|
94
100
|
'',
|
|
95
101
|
` status: ${row?.status ?? state.status ?? 'running'}`,
|
|
96
|
-
` phase: ${row?.phase ??
|
|
97
|
-
` current: ${
|
|
102
|
+
` phase: ${row?.phase ?? displayedPhase}`,
|
|
103
|
+
` current: ${displayedCurrent}`,
|
|
98
104
|
` goal: ${state.intent?.goal ?? state.intent?.description ?? '—'}`,
|
|
99
105
|
` orchestrator: ${state.orchestration?.selectedPool ?? state.orchestration?.requestedPool ?? 'auto/pending'} · ${state.orchestration?.selectedModel ?? 'connector model'} · ${state.orchestration?.selection ?? 'workflow-defined'}`,
|
|
100
106
|
` dir: ${row?.runDir ?? '—'}`,
|
|
@@ -129,6 +135,10 @@ export function renderDetails(row, { interactive = true } = {}) {
|
|
|
129
135
|
const attempt = state.attempts?.[attemptIndex];
|
|
130
136
|
if (attempt) {
|
|
131
137
|
lines.push(`${indent} ↳ attempt ${attempt.attemptNumber} · ${attempt.pool ?? '—'} · ${attempt.model ?? 'connector model'} · effort=${attempt.effort ?? 'auto'} · ${attempt.status} · ${attempt.startedAt ?? '—'}${attempt.finishedAt ? ` → ${attempt.finishedAt}` : ''}`);
|
|
138
|
+
if (attempt.routing) {
|
|
139
|
+
const candidates = (attempt.routing.candidates ?? []).map((candidate) => `${candidate.pool}:${candidate.pace}`).join(', ');
|
|
140
|
+
lines.push(`${indent} route: ${attempt.routing.reason}${candidates ? ` · candidates [${candidates}]` : ''}`);
|
|
141
|
+
}
|
|
132
142
|
lines.push(`${indent} ${compactUsage(attempt.usage)}`);
|
|
133
143
|
}
|
|
134
144
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
|
-
import { buildPoolsLive } from '../lib/config.js';
|
|
29
|
+
import { buildPools, buildPoolsLive } from '../lib/config.js';
|
|
30
30
|
import { getAllMeterReadings } from '../meters/registry.js';
|
|
31
31
|
import { WorkflowTui } from './tui.js';
|
|
32
32
|
import { runWorkflow, loadWorkflow } from './runner.js';
|
|
@@ -164,8 +164,9 @@ async function livePoolNames() {
|
|
|
164
164
|
getReadings: getAllMeterReadings,
|
|
165
165
|
});
|
|
166
166
|
return { names: pools.map((p) => p.name), pools };
|
|
167
|
-
} catch {
|
|
168
|
-
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const { pools } = buildPools(BULLSWARM_DIR(), Date.now());
|
|
169
|
+
return { names: pools.map((p) => p.name), pools, meterWarning: err.message };
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
package/src/workflow/goal.js
CHANGED
|
@@ -43,6 +43,7 @@ export function buildGoalWorkflow({
|
|
|
43
43
|
orchestrator = null,
|
|
44
44
|
name = null,
|
|
45
45
|
settings = {},
|
|
46
|
+
worktreeIsolation = 'agent-decides',
|
|
46
47
|
} = {}) {
|
|
47
48
|
if (typeof goal !== 'string' || !goal.trim()) {
|
|
48
49
|
throw new Error('goal text is required');
|
|
@@ -62,6 +63,14 @@ export function buildGoalWorkflow({
|
|
|
62
63
|
const maxWorkflowSeconds = positiveInt(settings.maxWorkflowSeconds, 3600, { max: 86_400 });
|
|
63
64
|
const concurrency = positiveInt(settings.concurrency, 3, { max: 16 });
|
|
64
65
|
const retryAttempts = positiveInt(settings.retryAttempts, 1, { min: 0, max: 3 });
|
|
66
|
+
if (!['agent-decides', 'off', 'required'].includes(worktreeIsolation)) {
|
|
67
|
+
throw new Error(`invalid worktree isolation policy "${worktreeIsolation}"`);
|
|
68
|
+
}
|
|
69
|
+
const worktreeInstruction = worktreeIsolation === 'required'
|
|
70
|
+
? 'Worktree isolation policy: required when the selected agent supports it.'
|
|
71
|
+
: worktreeIsolation === 'off'
|
|
72
|
+
? 'Worktree isolation policy: disabled; work in the supplied directory.'
|
|
73
|
+
: 'Worktree isolation policy: agent decides whether isolation is useful; do not introduce a worktree for routine sequential work.';
|
|
65
74
|
|
|
66
75
|
return {
|
|
67
76
|
schemaVersion: 'bullswarm.workflow.v1',
|
|
@@ -73,11 +82,12 @@ export function buildGoalWorkflow({
|
|
|
73
82
|
cwd: targetDir,
|
|
74
83
|
autonomous: true,
|
|
75
84
|
requestedOrchestrator: orchestrator ?? 'auto',
|
|
85
|
+
worktreeIsolation,
|
|
76
86
|
},
|
|
77
87
|
orchestration: {
|
|
78
88
|
mode: 'autonomous',
|
|
79
89
|
requestedPool: orchestrator ?? null,
|
|
80
|
-
selection: orchestrator ? 'user-pinned-for-testing' : 'capability-and-quota',
|
|
90
|
+
selection: orchestrator ? 'user-pinned-for-testing' : 'capability-strategy-and-quota',
|
|
81
91
|
completionPolicy: {
|
|
82
92
|
requireSuccessfulWorker: true,
|
|
83
93
|
requireSuccessfulVerification: true,
|
|
@@ -110,7 +120,7 @@ export function buildGoalWorkflow({
|
|
|
110
120
|
addDir: targetDir,
|
|
111
121
|
timeoutSec: Math.min(900, maxWorkflowSeconds),
|
|
112
122
|
},
|
|
113
|
-
prompt: AUTONOMOUS_ORCHESTRATOR_PROMPT
|
|
123
|
+
prompt: `${AUTONOMOUS_ORCHESTRATOR_PROMPT}\n\n${worktreeInstruction}`,
|
|
114
124
|
timeoutSec: Math.min(900, maxWorkflowSeconds),
|
|
115
125
|
onError: 'fail',
|
|
116
126
|
}],
|
package/src/workflow/runner.js
CHANGED
|
@@ -91,8 +91,16 @@ export async function runWorkflow(opts) {
|
|
|
91
91
|
delete state.cancellationLatencyMs;
|
|
92
92
|
delete state.cancelRequested;
|
|
93
93
|
delete state.cancelRequestedAt;
|
|
94
|
+
delete state.interruptionSignal;
|
|
95
|
+
delete state.interruptionRequestedAt;
|
|
96
|
+
delete state.interruptedAt;
|
|
97
|
+
delete state.recovery;
|
|
94
98
|
delete state.abortReason;
|
|
95
99
|
state.resumed = true;
|
|
100
|
+
// Commit cleared terminal/control markers before WorkflowRuntime begins
|
|
101
|
+
// merging dashboard-side state. Otherwise the first resume event can
|
|
102
|
+
// re-import the stale cancelRequested marker from the interrupted run.
|
|
103
|
+
writeFileSync(join(runDir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`);
|
|
96
104
|
} else {
|
|
97
105
|
if (resuming) {
|
|
98
106
|
throw new Error(`cannot resume: no state.json for run ${runId}`);
|
|
@@ -189,6 +197,29 @@ export async function runWorkflow(opts) {
|
|
|
189
197
|
onEvent: opts.onEvent,
|
|
190
198
|
env: opts.env,
|
|
191
199
|
});
|
|
200
|
+
state.runner = {
|
|
201
|
+
pid: process.pid,
|
|
202
|
+
status: 'running',
|
|
203
|
+
startedAt: new Date().toISOString(),
|
|
204
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
205
|
+
};
|
|
206
|
+
let interruptionSignal = state.interruptionSignal ?? null;
|
|
207
|
+
const requestInterruption = (signal) => {
|
|
208
|
+
if (state.finishedAt || interruptionSignal) return;
|
|
209
|
+
interruptionSignal = signal;
|
|
210
|
+
state.interruptionSignal = signal;
|
|
211
|
+
state.interruptionRequestedAt = new Date().toISOString();
|
|
212
|
+
state.cancelRequested = true;
|
|
213
|
+
state.cancelRequestedAt ??= state.interruptionRequestedAt;
|
|
214
|
+
state.status = 'interrupting';
|
|
215
|
+
state.stage = 'interrupting';
|
|
216
|
+
runtime.emit('run.interruption_requested', { signal, requestedAt: state.interruptionRequestedAt });
|
|
217
|
+
};
|
|
218
|
+
const onSigterm = () => requestInterruption('SIGTERM');
|
|
219
|
+
const onSigint = () => requestInterruption('SIGINT');
|
|
220
|
+
process.on('SIGTERM', onSigterm);
|
|
221
|
+
process.on('SIGINT', onSigint);
|
|
222
|
+
try {
|
|
192
223
|
state.availableCapabilities = {
|
|
193
224
|
pools: pools.map((pool) => ({
|
|
194
225
|
name: pool.name,
|
|
@@ -230,6 +261,7 @@ export async function runWorkflow(opts) {
|
|
|
230
261
|
let completedByPlanner = false;
|
|
231
262
|
let waitingForApproval = false;
|
|
232
263
|
let budgetExhausted = false;
|
|
264
|
+
let interrupted = false;
|
|
233
265
|
const retryAttempts = state.settings.retryAttempts ?? 1;
|
|
234
266
|
|
|
235
267
|
for (let pi = 0; pi < doc.phases.length && !aborted && !cancelled && !completedByPlanner && !waitingForApproval && !budgetExhausted; pi++) {
|
|
@@ -327,32 +359,43 @@ export async function runWorkflow(opts) {
|
|
|
327
359
|
try {
|
|
328
360
|
const diskState = JSON.parse(readFileSync(join(runDir, 'state.json'), 'utf8'));
|
|
329
361
|
cancelled ||= diskState.cancelRequested === true;
|
|
362
|
+
interruptionSignal ??= diskState.interruptionSignal ?? null;
|
|
330
363
|
} catch { /* use the in-memory cancellation state */ }
|
|
331
|
-
|
|
364
|
+
interrupted = Boolean(interruptionSignal);
|
|
365
|
+
if (cancelled && !interrupted) {
|
|
332
366
|
state.status = 'cancelling';
|
|
333
367
|
state.cancellingAt = new Date().toISOString();
|
|
334
368
|
runtime.emit('run.cancelling', { requestedAt: state.cancelRequestedAt ?? null });
|
|
335
369
|
}
|
|
336
|
-
state.status = cancelled ? 'cancelled' : waitingForApproval ? 'waiting_for_approval' : budgetExhausted ? 'budget_exhausted' : (aborted ? 'failed' : 'completed');
|
|
337
|
-
state.stage = cancelled ? 'cancelled' : waitingForApproval ? 'waiting_for_approval' : budgetExhausted ? 'budget_exhausted' : (aborted ? 'failed' : 'delivered');
|
|
370
|
+
state.status = interrupted ? 'interrupted' : cancelled ? 'cancelled' : waitingForApproval ? 'waiting_for_approval' : budgetExhausted ? 'budget_exhausted' : (aborted ? 'failed' : 'completed');
|
|
371
|
+
state.stage = interrupted ? 'interrupted' : cancelled ? 'cancelled' : waitingForApproval ? 'waiting_for_approval' : budgetExhausted ? 'budget_exhausted' : (aborted ? 'failed' : 'delivered');
|
|
338
372
|
delete state.currentPhase;
|
|
339
373
|
delete state.currentStep;
|
|
340
374
|
delete state.activeAgents;
|
|
341
|
-
if (cancelled) {
|
|
375
|
+
if (cancelled && !interrupted) {
|
|
342
376
|
state.cancelledAt = finishedAt;
|
|
343
377
|
const requested = Date.parse(state.cancelRequestedAt ?? '');
|
|
344
378
|
state.cancellationLatencyMs = Number.isFinite(requested) ? Math.max(0, Date.parse(finishedAt) - requested) : null;
|
|
345
379
|
}
|
|
346
|
-
if (
|
|
380
|
+
if (interrupted) {
|
|
381
|
+
state.interruptedAt = finishedAt;
|
|
382
|
+
state.abortReason = `runner interrupted by ${interruptionSignal}`;
|
|
383
|
+
state.recovery = { resumable: true, signal: interruptionSignal, interruptedAt: finishedAt };
|
|
384
|
+
}
|
|
385
|
+
if (abortReason && !interrupted) state.abortReason = abortReason;
|
|
347
386
|
runtime.persist();
|
|
348
387
|
|
|
349
388
|
const preliminaryReport = buildReport(state, doc, runDir);
|
|
350
|
-
runtime.emit(cancelled ? 'run.cancelled' : waitingForApproval ? 'run.waiting_for_approval' : budgetExhausted ? 'run.budget_exhausted' : 'run.completed', { runId, status: state.status, report: preliminaryReport.summary });
|
|
389
|
+
runtime.emit(interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : waitingForApproval ? 'run.waiting_for_approval' : budgetExhausted ? 'run.budget_exhausted' : 'run.completed', { runId, status: state.status, report: preliminaryReport.summary });
|
|
351
390
|
const report = buildReport(state, doc, runDir);
|
|
352
391
|
writeFileSync(join(runDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
|
|
353
392
|
opts.onEvent?.({ type: 'workflow.completed', runId, status: state.status, report: report.summary });
|
|
354
393
|
|
|
355
394
|
return { runId, runDir, state, report };
|
|
395
|
+
} finally {
|
|
396
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
397
|
+
process.removeListener('SIGINT', onSigint);
|
|
398
|
+
}
|
|
356
399
|
}
|
|
357
400
|
|
|
358
401
|
async function runDecisionLoop({ runtime, gate, phase, state, retryAttempts }) {
|
|
@@ -573,6 +616,13 @@ export function buildReport(state, doc, runDir) {
|
|
|
573
616
|
finishedAt: state.finishedAt,
|
|
574
617
|
resumed: state.resumed === true,
|
|
575
618
|
abortReason: state.abortReason ?? null,
|
|
619
|
+
interruption: state.interruptionSignal ? {
|
|
620
|
+
signal: state.interruptionSignal,
|
|
621
|
+
requestedAt: state.interruptionRequestedAt ?? null,
|
|
622
|
+
interruptedAt: state.interruptedAt ?? null,
|
|
623
|
+
} : null,
|
|
624
|
+
recovery: state.recovery ?? null,
|
|
625
|
+
resumeHistory: state.resumeHistory ?? [],
|
|
576
626
|
summary: {
|
|
577
627
|
stepsTotal: stepResults.length,
|
|
578
628
|
stepsOk: simpleOk,
|
package/src/workflow/runtime.js
CHANGED
|
@@ -74,6 +74,14 @@ export class WorkflowRuntime {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
persist() {
|
|
77
|
+
this.state.runner = {
|
|
78
|
+
...(this.state.runner ?? {}),
|
|
79
|
+
pid: process.pid,
|
|
80
|
+
status: this.state.finishedAt ? this.state.status : 'running',
|
|
81
|
+
startedAt: this.state.runner?.startedAt ?? this.state.startedAt ?? new Date().toISOString(),
|
|
82
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
83
|
+
...(this.state.finishedAt ? { finishedAt: this.state.finishedAt } : {}),
|
|
84
|
+
};
|
|
77
85
|
// The dashboard may write cancelRequested while a dispatch is running.
|
|
78
86
|
// Preserve that marker when the runner persists its in-memory snapshot.
|
|
79
87
|
try {
|
|
@@ -272,6 +280,15 @@ export class WorkflowRuntime {
|
|
|
272
280
|
taskFile: attemptPaths.taskFile,
|
|
273
281
|
outFile: attemptPaths.outFile,
|
|
274
282
|
why: null,
|
|
283
|
+
routing: {
|
|
284
|
+
reason: route.why,
|
|
285
|
+
candidates: route.candidates,
|
|
286
|
+
effort: effortTier,
|
|
287
|
+
lane: step.lane ?? 'chore',
|
|
288
|
+
requiredCapabilities: step.requiresCapabilities ?? [],
|
|
289
|
+
configuredAssignment: assignment ?? null,
|
|
290
|
+
assignmentApplied: assignment?.pool === conn.name ? assignment : null,
|
|
291
|
+
},
|
|
275
292
|
};
|
|
276
293
|
if (step.type === 'decide' && this.state.orchestration) {
|
|
277
294
|
this.state.orchestration.selections ??= [];
|
|
@@ -284,13 +301,16 @@ export class WorkflowRuntime {
|
|
|
284
301
|
this.state.orchestration.selectedPool = conn.name;
|
|
285
302
|
this.state.orchestration.selectedModel = attemptRecord.model;
|
|
286
303
|
this.state.orchestration.selections.push(selection);
|
|
287
|
-
this.emit('orchestrator.selected', selection);
|
|
304
|
+
this.emit('orchestrator.selected', { ...selection, routing: attemptRecord.routing });
|
|
288
305
|
}
|
|
289
306
|
this.state.attempts.push(attemptRecord);
|
|
290
307
|
action.attempts.push(this.state.attempts.length - 1);
|
|
291
308
|
action.status = 'running';
|
|
292
309
|
action.startedAt ??= startedAt;
|
|
293
|
-
this.emit('attempt.started', {
|
|
310
|
+
this.emit('attempt.started', {
|
|
311
|
+
actionId, attemptNumber, pool: conn.name, model: attemptRecord.model,
|
|
312
|
+
routing: attemptRecord.routing,
|
|
313
|
+
});
|
|
294
314
|
this.emit('action.started', { actionId, attemptNumber });
|
|
295
315
|
|
|
296
316
|
this.emit('step.started', {
|
|
@@ -413,7 +433,7 @@ export class WorkflowRuntime {
|
|
|
413
433
|
|
|
414
434
|
// R7: record EVERY dispatch into the shared decisionLog so
|
|
415
435
|
// `bullswarm health` can correlate workflow outputs.
|
|
416
|
-
this.appendDecision(step, conn.name, verdict, attemptPaths);
|
|
436
|
+
this.appendDecision(step, conn.name, verdict, attemptPaths, attemptRecord.routing);
|
|
417
437
|
|
|
418
438
|
// R7: auth/throttle verdict → quarantine the pool for 10 min so
|
|
419
439
|
// the next dispatch doesn't re-select it.
|
|
@@ -494,7 +514,7 @@ export class WorkflowRuntime {
|
|
|
494
514
|
);
|
|
495
515
|
}
|
|
496
516
|
|
|
497
|
-
appendDecision(step, poolName, verdict, paths) {
|
|
517
|
+
appendDecision(step, poolName, verdict, paths, routing = null) {
|
|
498
518
|
try {
|
|
499
519
|
const coreState = loadState(this.bullswarmDir);
|
|
500
520
|
coreState.decisionLog ??= [];
|
|
@@ -508,6 +528,7 @@ export class WorkflowRuntime {
|
|
|
508
528
|
wallSec: verdict.meta?.wallSec,
|
|
509
529
|
model: verdict.pick?.model ?? null,
|
|
510
530
|
usage: verdict.meta?.usage ?? null,
|
|
531
|
+
routing,
|
|
511
532
|
outFile: paths?.outFile ?? null,
|
|
512
533
|
source: 'workflow',
|
|
513
534
|
stepId: step.id,
|
package/src/workflow/short-id.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// not the generator.
|
|
17
17
|
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
|
-
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
19
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
+
import { appendEvent } from './events.js';
|
|
21
22
|
|
|
22
23
|
export const SHORT_ID_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
23
24
|
export const SHORT_ID_LEN = 6;
|
|
@@ -111,9 +112,8 @@ export function resolveRunId(bullswarmDir, token) {
|
|
|
111
112
|
/**
|
|
112
113
|
* Read all runs (one entry per `wf-...` subdir of `~/.bullswarm/workflows/`).
|
|
113
114
|
* Each entry includes the `state.json` summary + the `report.json`
|
|
114
|
-
* summary if it exists.
|
|
115
|
-
*
|
|
116
|
-
* is gone).
|
|
115
|
+
* summary if it exists. Active states are reconciled against the persisted
|
|
116
|
+
* owner PID and heartbeat before the `ongoing` field is computed.
|
|
117
117
|
*/
|
|
118
118
|
export function listRuns(bullswarmDir) {
|
|
119
119
|
const runsRoot = join(bullswarmDir, 'workflows');
|
|
@@ -129,6 +129,7 @@ export function listRuns(bullswarmDir) {
|
|
|
129
129
|
if (existsSync(sf)) {
|
|
130
130
|
try { state = JSON.parse(readFileSync(sf, 'utf8')); } catch { /* corrupt */ }
|
|
131
131
|
}
|
|
132
|
+
if (state) state = reconcileInterruptedRun(dir, state);
|
|
132
133
|
if (existsSync(rf)) {
|
|
133
134
|
try { report = JSON.parse(readFileSync(rf, 'utf8')); } catch { /* corrupt */ }
|
|
134
135
|
}
|
|
@@ -165,6 +166,94 @@ export function listRuns(bullswarmDir) {
|
|
|
165
166
|
*/
|
|
166
167
|
export const ONGOING_GRACE_MS = 90_000;
|
|
167
168
|
|
|
169
|
+
const ACTIVE_STATUSES = new Set(['queued', 'running', 'cancelling', 'interrupting']);
|
|
170
|
+
|
|
171
|
+
export function isProcessAlive(pid) {
|
|
172
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0);
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convert an ownerless active state into an explicit resumable interruption.
|
|
183
|
+
* This is recovery, not garbage collection: outputs and attempts remain and
|
|
184
|
+
* `workflow goal --resume <id>` can continue from the durable workflow.
|
|
185
|
+
*/
|
|
186
|
+
export function reconcileInterruptedRun(runDir, state, {
|
|
187
|
+
now = Date.now(), processAlive = isProcessAlive,
|
|
188
|
+
} = {}) {
|
|
189
|
+
if (!state || state.finishedAt || !ACTIVE_STATUSES.has(state.status)) return state;
|
|
190
|
+
if (state.status === 'waiting_for_approval' || state.status === 'paused') return state;
|
|
191
|
+
const statePath = join(runDir, 'state.json');
|
|
192
|
+
let modifiedAt = 0;
|
|
193
|
+
try { modifiedAt = statSync(statePath).mtimeMs; } catch { return state; }
|
|
194
|
+
const heartbeatAt = Date.parse(
|
|
195
|
+
state.runner?.lastHeartbeatAt
|
|
196
|
+
?? Object.values(state.activeAgents ?? {}).map((agent) => agent.lastHeartbeatAt).filter(Boolean).sort().at(-1)
|
|
197
|
+
?? '',
|
|
198
|
+
);
|
|
199
|
+
const lastLiveAt = Number.isFinite(heartbeatAt) ? heartbeatAt : modifiedAt;
|
|
200
|
+
const fresh = (now - lastLiveAt) < ONGOING_GRACE_MS;
|
|
201
|
+
const pid = state.runner?.pid ?? null;
|
|
202
|
+
const ownerAlive = pid != null && processAlive(pid);
|
|
203
|
+
// A live PID alone is insufficient because PIDs can be reused. Persisted
|
|
204
|
+
// heartbeats let us require both identity signals for modern run states.
|
|
205
|
+
if ((pid != null && ownerAlive && fresh) || (pid == null && fresh)) return state;
|
|
206
|
+
|
|
207
|
+
const reconciledAt = new Date(now).toISOString();
|
|
208
|
+
const reason = pid == null
|
|
209
|
+
? 'runner heartbeat expired before a terminal state was persisted'
|
|
210
|
+
: `runner process ${pid} exited before a terminal state was persisted`;
|
|
211
|
+
state.status = 'interrupted';
|
|
212
|
+
state.stage = 'interrupted';
|
|
213
|
+
state.finishedAt = reconciledAt;
|
|
214
|
+
state.interruptedAt = reconciledAt;
|
|
215
|
+
state.abortReason = reason;
|
|
216
|
+
state.recovery = { resumable: true, reconciledAt, reason };
|
|
217
|
+
state.runner = { ...(state.runner ?? {}), status: 'interrupted', finishedAt: reconciledAt };
|
|
218
|
+
for (const attempt of state.attempts ?? []) {
|
|
219
|
+
if (attempt.status === 'running') {
|
|
220
|
+
attempt.status = 'abandoned';
|
|
221
|
+
attempt.finishedAt = reconciledAt;
|
|
222
|
+
attempt.why = reason;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const action of state.actionLedger ?? []) {
|
|
226
|
+
if (['running', 'retry_scheduled', 'queued'].includes(action.status)) {
|
|
227
|
+
action.status = 'interrupted';
|
|
228
|
+
action.finishedAt = reconciledAt;
|
|
229
|
+
action.why = reason;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
delete state.activeAgents;
|
|
233
|
+
delete state.currentPhase;
|
|
234
|
+
delete state.currentStep;
|
|
235
|
+
appendEvent(runDir, state, 'run.interrupted_reconciled', { reason, resumable: true });
|
|
236
|
+
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
237
|
+
return state;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function reconcileInterruptedRuns(bullswarmDir, opts = {}) {
|
|
241
|
+
const runsRoot = join(bullswarmDir, 'workflows');
|
|
242
|
+
if (!existsSync(runsRoot)) return [];
|
|
243
|
+
const changed = [];
|
|
244
|
+
for (const name of readdirSync(runsRoot)) {
|
|
245
|
+
const runDir = join(runsRoot, name);
|
|
246
|
+
const statePath = join(runDir, 'state.json');
|
|
247
|
+
if (!name.startsWith('wf-') || !existsSync(statePath)) continue;
|
|
248
|
+
let state;
|
|
249
|
+
try { state = JSON.parse(readFileSync(statePath, 'utf8')); } catch { continue; }
|
|
250
|
+
const before = state.status;
|
|
251
|
+
const next = reconcileInterruptedRun(runDir, state, opts);
|
|
252
|
+
if (before !== next.status && next.status === 'interrupted') changed.push(name);
|
|
253
|
+
}
|
|
254
|
+
return changed;
|
|
255
|
+
}
|
|
256
|
+
|
|
168
257
|
export function isOngoing(runDir, state) {
|
|
169
258
|
if (state && state.status && state.finishedAt) return false;
|
|
170
259
|
try {
|