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 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 # discover local models and refresh tier suggestions
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
- Setup recommends an initial refresh; run `strategy refresh` whenever a CLI's
67
- catalog or subscription changes. Discovery commands, model argument syntax,
68
- pricing, and benchmark declarations remain connector-owned. Unknown license
69
- value, prices, and benchmarks stay `null` rather than being guessed. An
70
- assignment is only a preference: quarantine, exhaustion, burst gates, and
71
- capability checks still win.
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 selects an eligible `workflow-planning` orchestrator by live quota
98
- surplus. The orchestrator observes durable evidence, proposes bounded actions,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": {
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, and live quota
194
- produce high/medium/low suggestions; unknown evidence remains null. A step's
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, usage/cost estimate, status, task/output artifacts, timings, failure
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
- if (opts.json) console.log(JSON.stringify({ ok: true, mode: 'auto', ...r }, null, 2));
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
@@ -21,6 +21,7 @@ export const DEFAULT_STATE = {
21
21
  config: {
22
22
  depthLimit: 2,
23
23
  callerName: 'claude-code',
24
+ worktreeIsolation: 'agent-decides',
24
25
  },
25
26
  };
26
27
 
@@ -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 when declared (quality rank fallback), then live quota surplus and cost rank'
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. Integration blocks diff + approval
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')],
@@ -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
- console.log(opts.json ? JSON.stringify(report, null, 2) : render(report));
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('usage: bullswarm strategy <show|refresh|recommend|set-subscription|assign|clear-assignment>');
239
+ throw new Error(strategyUsage());
124
240
  } catch (err) {
125
241
  console.error(`✗ ${err.message}`);
126
242
  return 1;
@@ -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('usage: bullswarm workflow goal "<goal>" [--cwd <dir>] [--detach] [--json]');
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-capable, enabled, non-quarantined, non-burst-gated pools',
350
- modelSelection: 'connector-defined; bullswarm does not infer intelligence or change model unless connector command pins one',
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
- return { names: [], pools: [] };
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 ?? 'starting'}`,
97
- ` current: ${state.currentStep?.id ?? '—'}`,
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
- return { names: [], pools: [] };
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
 
@@ -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
  }],
@@ -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
- if (cancelled) {
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 (abortReason) state.abortReason = abortReason;
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,
@@ -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', { actionId, attemptNumber, pool: conn.name, model: attemptRecord.model });
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,
@@ -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. The `ongoing` field is computed by trying to
115
- * take an exclusive flock on `state.json` (proves the writer process
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 {