bullswarm 0.29.0 → 0.29.1

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,27 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.29.1 — a revised cancelled run runs its cancelled steps again
4
+
5
+ - workflow: revising a cancelled run reopened it but left every step the
6
+ cancellation had stopped as `cancelled`, so the kernel finalized `partial`
7
+ in the same second and nothing ran. Found on a real claude-code run
8
+ (`pxkr7s`, revision 7). Reopening now returns those steps to pending, lists
9
+ them as `reopened.requeued` in the revise output and the `workflow.reopened`
10
+ event, and never counts their earlier attempts as completion. Failed steps
11
+ still wait for an explicit `--rerun`.
12
+ - watch: a run waiting for its caller planner now also prints the
13
+ `plan export` / `plan revise` commands next to `plan show`.
14
+ - workflow: an attempt stopped by a plan revision or a pause now records
15
+ `failureKind: superseded` or `paused` on the attempt and in
16
+ `attempt.finished`, the same kind `action.finished` already carried. It was
17
+ recorded as a plain `cancelled`, which made deliberate steering look like
18
+ worker failures.
19
+ - strategy: `strategy rungs` no longer counts a stopped dispatch against a
20
+ pool's "% ok". On 2026-09-14 all 11 claude-code dispatches recorded as not
21
+ ok were workflow cancellations, plan revisions or pauses, none a worker
22
+ failure. Dispatch records now carry `failureKind`. The share was display
23
+ only; routing never read it.
24
+
3
25
  ## 0.29.0 — steer a running workflow, providers become directories, a declared reset date
4
26
 
5
27
  - workflow: the plan of a caller-planned program run can be changed at any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
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
@@ -174,7 +174,9 @@ Rules that matter when you edit:
174
174
  must not remain, give a new or amended step the job of reverting or repairing
175
175
  them.
176
176
  - Revising a finished run (`completed`, `partial`, `cancelled`) reopens it: the
177
- new plan runs and the run finishes again with a new result.
177
+ new plan runs and the run finishes again with a new result. Steps a
178
+ cancellation stopped run again; failed steps run again only when named in
179
+ `rerun`.
178
180
  - To think without new work starting, pause first: `bullswarm workflow pause
179
181
  <shortId>` starts nothing new and lets running agents finish (`--now` stops
180
182
  them; they run again after resume). Export and revise while paused, then
@@ -209,8 +209,10 @@ being replaced is stopped before the new plan is committed
209
209
  CLI applies the revision itself under the kernel lease and relaunches the
210
210
  kernel detached, unless the run is paused. A finished run is reopened: its
211
211
  `result.json` moves to `result-before-revision-<n>.json`, a
212
- `workflow.reopened` event is written, and the new plan runs to a new result. A
213
- revision also clears a caller-planner wait, so it can replace `plan submit`.
212
+ `workflow.reopened` event is written, and the new plan runs to a new result.
213
+ Steps a cancellation stopped return to pending (`reopened.requeued`); failed
214
+ steps stay failed unless the revision names them in `rerun`. A revision also
215
+ clears a caller-planner wait, so it can replace `plan submit`.
214
216
 
215
217
  Stopping a process does not undo its edits in the shared tree. When a stopped
216
218
  or removed step's partial changes must go, amend it or add a step whose prompt
@@ -303,7 +303,11 @@ export function rungRecord(decisionLog, pool, tier) {
303
303
  const window = attemptWindow(row);
304
304
  if (window) minutes.push(window.minutes);
305
305
  }
306
- const verdicts = rows.filter((row) => typeof row.ok === 'boolean');
306
+ // A dispatch someone stopped (a workflow cancel, a plan revision, a pause) is
307
+ // not a verdict on the pool. Older rows carry no failureKind, only the
308
+ // dispatcher's fixed cancellation message.
309
+ const stopped = (row) => row.failureKind === 'cancelled' || row.why === 'workflow cancellation requested';
310
+ const verdicts = rows.filter((row) => typeof row.ok === 'boolean' && !stopped(row));
307
311
  return {
308
312
  dispatches: rows.length,
309
313
  medianMinutes: minutes.length ? Math.round(medianOf(minutes) * 100) / 100 : null,
@@ -452,7 +452,9 @@ export function workflowPanelModel(row, { phaseIndex = null, agentIndex = null }
452
452
  agents.push({
453
453
  key: `attempt:${attempt.id}`, action, attempt: { ...attempt, attemptNumber: attempt.ordinal, outFile: attempt.outputFile },
454
454
  active: attempt.status === 'running' ? { ...attempt, stepId: action.id, attempt: attempt.ordinal, outFile: attempt.outputFile } : null,
455
- pool: attempt.pool ?? 'unassigned', model: attempt.model ?? 'connector model', status: attempt.status,
455
+ pool: attempt.pool ?? 'unassigned', model: attempt.model ?? 'connector model',
456
+ // A worker stopped by a plan revision or a pause did not fail; say why it stopped.
457
+ status: attempt.status === 'cancelled' && ['superseded', 'paused'].includes(attempt.failureKind) ? attempt.failureKind : attempt.status,
456
458
  });
457
459
  }
458
460
  }
@@ -1326,7 +1328,7 @@ function statusIcon(status, spinnerFrame = 0) {
1326
1328
  if (value.includes('waiting') || value === 'queued' || value === 'paused'
1327
1329
  || value === 'blocked' || value === 'starting' || value === 'reviewing evidence'
1328
1330
  || value === 'directing execution') return glyphs().waiting;
1329
- if (value === 'skipped' || value === 'removed') return '–';
1331
+ if (value === 'skipped' || value === 'removed' || value === 'superseded') return '–';
1330
1332
  return glyphs().pending;
1331
1333
  }
1332
1334
 
@@ -417,7 +417,7 @@ export async function dispatchV2Action({
417
417
  onAttempt?.('finished', clone(record), verdict);
418
418
  appendDecision(bullswarmDir, {
419
419
  ts: finishedAt, lane: action.lane ?? 'chore', picked: pool.name,
420
- keepOnClaude: false, ok: verdict.ok, why: verdict.why ?? null,
420
+ keepOnClaude: false, ok: verdict.ok, failureKind: verdict.ok ? null : kind, why: verdict.why ?? null,
421
421
  wallSec: verdict.meta?.wallSec ?? null, model: record.model,
422
422
  reasoning: clone(reasoning),
423
423
  usage: verdict.meta?.usage ?? null, routing: record.routing,
@@ -33,7 +33,7 @@ import { scoutPrompt } from './goal.js';
33
33
  import {
34
34
  createIsolatedWorkspace, disposeIsolatedWorkspace, integrateIsolatedWorkspace,
35
35
  } from './v2-workspace.js';
36
- import { presentationStageStatus, stageForAction } from './v2-presentation.js';
36
+ import { deriveV2LiveStages, presentationStageStatus, stageForAction } from './v2-presentation.js';
37
37
  import { deliverSteering, peekSteering, readSteering } from './steering.js';
38
38
  import { enforcesOwnership, isProgramWorkflow, v2SchedulingOptions } from './execution-policy.js';
39
39
  import { buildWorkspaceReport, captureWorkspaceStatus } from './workspace-report.js';
@@ -264,8 +264,22 @@ function commitRevisionUnderLease(runDir, request, { now }) {
264
264
  if (state.cancellation.requested) state.cancellation = { requested: false, requestedAt: null, reason: null };
265
265
  rmSync(cancelFile, { force: true });
266
266
  if (['completed', 'cancelled', 'failed'].includes(state.planner.status)) state.planner.status = 'waiting';
267
- reopened = { previousStatus, archivedResult: hadResult ? archived : null };
268
- appendEvent(runDir, state, 'workflow.reopened', { previousStatus, requestId: request.id, archivedResult: reopened.archivedResult });
267
+ // Steps the cancellation stopped were never judged; reopening the run is
268
+ // what lifts that cancellation, so they run again. Their earlier attempts
269
+ // stay on record but never count as this step's completion. Failed steps
270
+ // are left as they are: rerunning them is the caller's decision.
271
+ const requeued = [];
272
+ for (const action of state.actions) {
273
+ if (action.status !== 'cancelled') continue;
274
+ Object.assign(action, {
275
+ status: 'pending', startedAt: null, finishedAt: null, outputFile: null, artifactIds: [],
276
+ lastFailure: null, supersededAttempts: action.attempts,
277
+ });
278
+ requeued.push(action.id);
279
+ }
280
+ if (requeued.length) state.presentation.stages = deriveV2LiveStages(state, { revision: state.program.revision, at });
281
+ reopened = { previousStatus, archivedResult: hadResult ? archived : null, requeued };
282
+ appendEvent(runDir, state, 'workflow.reopened', { previousStatus, requestId: request.id, archivedResult: reopened.archivedResult, requeued });
269
283
  }
270
284
  // A revision answers a caller-planner pause as fully as a submission does.
271
285
  if (state.planner.awaiting) {
@@ -1307,6 +1321,10 @@ async function runV2Kernel({
1307
1321
  emit('attempt.started', { actionId: action.id, attemptId: currentAttemptId, pool: record.pool, model: record.model, reasoning: clone(record.reasoning ?? null) });
1308
1322
  } else {
1309
1323
  lease.assertOwner();
1324
+ // An attempt stopped by a plan revision or a pause is not a failure of
1325
+ // its pool: record why it stopped, the same kind action.finished carries.
1326
+ const stop = record.status === 'cancelled' && !interrupted ? stopRequested.get(action.id) ?? null : null;
1327
+ if (stop) record = { ...record, failureKind: stop.kind, why: stop.message };
1310
1328
  if (record.status === 'succeeded') writeCompletionReceipt(receiptPath, {
1311
1329
  attemptId: currentAttemptId, finishedAt: record.finishedAt, before, isolated,
1312
1330
  verdict: verdict ?? { ok: true, outFile: record.outFile ?? record.outputFile },
@@ -1460,10 +1478,10 @@ async function runV2Kernel({
1460
1478
  runtime.finishedAt = finishedAt;
1461
1479
  runtime.lastFailure = stop ? { kind: stop.kind, message: stop.message } : { kind: 'runtime', message: error?.message || String(error) };
1462
1480
  for (const attempt of state.attempts) if (attempt.actionId === action.id && attempt.status === 'running') {
1463
- Object.assign(attempt, { status: runtime.status, finishedAt, failureKind: 'runtime', why: runtime.lastFailure.message });
1481
+ Object.assign(attempt, { status: runtime.status, finishedAt, failureKind: runtime.lastFailure.kind, why: runtime.lastFailure.message });
1464
1482
  runtime.outputFile ??= attempt.outputFile;
1465
1483
  }
1466
- emit('action.finished', { actionId: action.id, status: runtime.status, failureKind: 'runtime', why: runtime.lastFailure.message });
1484
+ emit('action.finished', { actionId: action.id, status: runtime.status, failureKind: runtime.lastFailure.kind, why: runtime.lastFailure.message });
1467
1485
  completePresentationStages();
1468
1486
  }
1469
1487
  };
@@ -867,7 +867,8 @@ export async function runWorkflowWatch(bullswarmDir, token, {
867
867
  output.write(`outcome: waiting for the caller planner (${snapshot.awaitingPlanner.boundary} boundary)\n`);
868
868
  output.write(snapshot.cancellationRequested
869
869
  ? `next: cancellation requested; bullswarm workflow cancel ${runToken} --json finalizes it\n`
870
- : `next: bullswarm workflow plan show ${runToken} --json\n`);
870
+ : `next: bullswarm workflow plan show ${runToken} --json\n`
871
+ + ` or revise: bullswarm workflow plan export ${runToken} --out plan.json, then bullswarm workflow plan revise ${runToken} --program plan.json\n`);
871
872
  }
872
873
  return 0;
873
874
  }