bullswarm 0.25.1 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.25.2 — event-based watch
4
+
5
+ - `workflow watch <run> --next` is safe to relaunch after every wake-up: each
6
+ such exit prints `next: bullswarm workflow watch <id> --next --after <seq>
7
+ --since <time>`; relaunching with those values replays notable events that
8
+ landed while no watcher was attached, reports a level at most once, and does
9
+ not repeat a stall already reported (its recovery line still prints). `--jsonl`
10
+ objects carry `sequence`.
11
+
12
+ - `workflow watch` for V2 runs is event-based by default: one attach line,
13
+ then one line per notable event (action finished/failed/blocked/cancelled,
14
+ evidence, stage completion, planner turn, stall/recovery, cancellation)
15
+ and silence while work is merely in progress. `--next` prints no attach
16
+ line and exits after the first notable event (0 while the run continues or
17
+ delivered, 1 when it ended without delivering or the kernel is not
18
+ running). `--stall-after <seconds>` (default 300) reports a silent running
19
+ agent; `--heartbeat <seconds>` is opt-in for V2 (legacy still defaults to
20
+ 60s). `--jsonl` emits one object per event with a stable `type`. Agent
21
+ starts, mechanical retries, and steering delivery remain `--verbose` only.
22
+ `--once` and legacy (non-V2) transition-plus-heartbeat output are
23
+ unchanged.
24
+
3
25
  ## 0.25.0 — shared programs that finish with the graph
4
26
 
5
27
  - New goal workflows share the target worktree by default. File territories
package/README.md CHANGED
@@ -302,7 +302,9 @@ The detached response includes a short ID and exact observation commands:
302
302
 
303
303
  ```bash
304
304
  bullswarm workflow runs show <shortId>
305
- bullswarm workflow watch <shortId> # low-noise live progress + terminal timing
305
+ bullswarm workflow watch <shortId> # V2: attach, then one line per notable event
306
+ bullswarm workflow watch <shortId> --next # print the next notable event and exit
307
+ # relaunch with the --after/--since it prints
306
308
  bullswarm workflow # unified human workflow home
307
309
  bullswarm workflow tui <shortId> # jump directly to one run timeline
308
310
  bullswarm workflow tui --json <shortId>
@@ -482,20 +484,45 @@ auditing completed runs.
482
484
 
483
485
  ### Live workflow dashboard
484
486
 
485
- For ordinary observation, use the non-interactive watcher. Human output is one
486
- compact aggregate line per semantic change and a 60-second heartbeat while
487
- otherwise quiet. Each line reports status/location, events and agent actions
488
- captured since the preceding sample, and quiet duration. It does not repeat
489
- command or response excerpts. Use `--verbose` for the detailed per-agent and
490
- last-action view. Compact terminal output reports the overall attempt count and
491
- elapsed time; `--verbose` includes every attempt's agent/model, outcome, and
492
- tokens. This keeps agent monitoring cheap while retaining a drill-down path.
487
+ For ordinary observation, use the non-interactive watcher. For V2 runs it
488
+ prints one attach line, then one line per notable event as it happens
489
+ (action finished/failed/blocked/cancelled, evidence, stage completion,
490
+ planner turn, stall/recovery, cancellation, and the existing pause and
491
+ terminal `outcome:` / `next:` lines) and stays silent while work is merely
492
+ in progress. Agent starts, mechanical retries, and steering delivery print
493
+ only with `--verbose`. The periodic heartbeat is off unless you pass
494
+ `--heartbeat <seconds>`; `--stall-after <seconds>` (default 300) reports a
495
+ running agent that has gone silent. `--next` prints no attach line and
496
+ exits after the first notable event so a background terminal can wake the
497
+ caller; relaunch until the outcome line reports a pause or a terminal
498
+ status (exit 0 while the run continues or delivered, 1 when it ended
499
+ without delivering or the kernel is not running). Every `--next` exit that
500
+ leaves the run going ends with a relaunch line —
501
+ `next: bullswarm workflow watch <shortId> --next --after <sequence> --since <iso>` —
502
+ and the relaunch should copy those two values verbatim: `--after` starts
503
+ from the durable event sequence the previous watcher consumed, so events
504
+ committed while nothing was attached are printed instead of skipped, and
505
+ `--since` is that watcher's exit time, so an agent whose silence it already
506
+ reported does not produce a duplicate stall line (its recovery still
507
+ prints). `--jsonl` emits one JSON object per notable event with a stable
508
+ `type` (`attach`, `action.finished`,
509
+ `evidence.recorded`, `stage.completed`, `planner.finished`, `agent.stalled`,
510
+ `agent.recovered`, `cancellation.requested`, `paused`, `finished`,
511
+ `interrupted`, and with `--verbose` `action.started`, `attempt.retrying`,
512
+ `steering.delivered`); in that mode the relaunch line is not printed and
513
+ every object instead carries the `sequence` it was emitted at, which is the
514
+ value to pass as `--after`. `--once` still prints one current snapshot. Legacy
515
+ (non-V2) runs keep the compact transition-plus-heartbeat stream unchanged.
493
516
 
494
517
  ```bash
495
518
  bullswarm workflow watch <shortId>
496
- bullswarm workflow watch <shortId> --jsonl # automation-friendly stream
519
+ bullswarm workflow watch <shortId> --next # next notable event, then exit
520
+ bullswarm workflow watch <shortId> --next --after 42 --since 2026-09-08T10:15:00.000Z
521
+ # the relaunch: values copied from the previous next: line
522
+ bullswarm workflow watch <shortId> --jsonl # one JSON object per event
497
523
  bullswarm workflow watch <shortId> --once # one current/terminal snapshot
498
- bullswarm workflow watch <shortId> --verbose # detailed agent/action view
524
+ bullswarm workflow watch <shortId> --verbose # started / retry / steering too
525
+ bullswarm workflow watch <shortId> --stall-after 120 --heartbeat 30
499
526
  ```
500
527
 
501
528
  `workflow tui` is the interactive, Claude-style `/workflows` view. For an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
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
@@ -109,11 +109,22 @@ validate again. A valid launch detaches and returns `shortId`; report it.
109
109
  ## 3. Observe and judge the result
110
110
 
111
111
  ```bash
112
- bullswarm workflow watch <shortId>
112
+ bullswarm workflow watch <shortId> --next
113
+ bullswarm workflow watch <shortId> --next --after <sequence> --since <iso-timestamp>
113
114
  bullswarm workflow runs result <shortId> --json
114
115
  ```
115
116
 
116
117
  When the user asked you to complete the work, follow the run through its result.
118
+ Launch `bullswarm workflow watch <shortId> --next` in a background terminal, act
119
+ on the printed event when it exits, and relaunch until the outcome line reports
120
+ a pause or a terminal status. Each exit that leaves the run going ends with
121
+ `next: bullswarm workflow watch <shortId> --next --after <sequence> --since <iso>`:
122
+ relaunch with exactly those two values, so events committed while you were
123
+ acting are printed instead of skipped and a stall you already saw does not
124
+ report twice. In `--jsonl` mode there is no such line — take `--after` from the
125
+ `sequence` field of the last object. V2 watch prints one line per notable event
126
+ and stays silent while work is merely in progress; `--heartbeat` is opt-in and
127
+ `--stall-after` (default 300s) reports a silent running agent.
117
128
  `watch` also exits at a durable planning pause; that is not completion.
118
129
 
119
130
  Read action outputs and actual artifacts, and probe important edge cases yourself.
@@ -30,6 +30,8 @@ deprecated alias for `--orchestrator <pool> --orchestrator-strict`.
30
30
  Observe and consume:
31
31
 
32
32
  ```bash
33
+ bullswarm workflow watch <shortId> --next
34
+ bullswarm workflow watch <shortId> --next --after <sequence> --since <iso-timestamp>
33
35
  bullswarm workflow watch <shortId>
34
36
  bullswarm workflow tui <shortId>
35
37
  bullswarm workflow tui --json <shortId>
@@ -37,7 +39,19 @@ bullswarm workflow events --json <shortId> --after 0
37
39
  bullswarm workflow runs result <shortId> --json
38
40
  ```
39
41
 
40
- The default watch is a compact heartbeat. Use `--verbose` only for diagnosis.
42
+ V2 watch prints one attach line, then one line per notable event, and stays
43
+ silent while work is merely in progress. Launch
44
+ `bullswarm workflow watch <shortId> --next` in a background terminal, act on the
45
+ printed event when it exits, and relaunch until the outcome line reports a
46
+ pause or a terminal status. Every `--next` exit that leaves the run going ends
47
+ with `next: bullswarm workflow watch <shortId> --next --after <sequence> --since <iso>`;
48
+ relaunch with those exact `--after` and `--since` values so events committed
49
+ while no watcher was attached are printed rather than skipped and an
50
+ already-reported stall does not fire again (its recovery still prints). With
51
+ `--jsonl` that line is absent: take `--after` from the `sequence` field carried
52
+ by every emitted object. `--heartbeat` is opt-in for V2 (legacy still
53
+ defaults to 60s). `--stall-after` (default 300s) reports a silent running
54
+ agent. Use `--verbose` only for diagnosis.
41
55
  The result command is the stable delivery/verification envelope; do not scrape
42
56
  task files or assume the last provider response is the deliverable.
43
57
 
package/src/help.js CHANGED
@@ -987,25 +987,39 @@ const workflowTuiText = rich({
987
987
  });
988
988
 
989
989
  const workflowWatchText = rich({
990
- usage: 'bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--jsonl] [--once] [--verbose]',
991
- purpose: "Follow one run's progress with low noise: prints only semantic changes plus a "
992
- + 'periodic heartbeat, then a timing breakdown at completion. Each line shows two silences: '
993
- + '"quiet" is time since the last durable workflow event, "agent output ago" is time since a '
994
- + 'live agent last produced output, so a thinking agent and a dead one look different. Distinct from the '
990
+ usage: 'bullswarm workflow watch <runId> [--interval <seconds>] [--heartbeat <seconds>] [--stall-after <seconds>] [--next [--after <sequence>] [--since <iso-timestamp>]] [--jsonl] [--once] [--verbose]',
991
+ purpose: "Follow one V2 run by printing one attach line, then one line per notable event "
992
+ + '(action finished/failed/blocked/cancelled, evidence, stage completion, stall/recovery, planning, '
993
+ + 'cancellation) and staying silent while work is merely in progress. `--next` prints no attach '
994
+ + 'line and returns after the first notable event, or immediately at a pause or terminal status. '
995
+ + 'Every `--next` exit that leaves the run going prints a `next:` relaunch line carrying `--after` and '
996
+ + '`--since`; pass those two values back on the relaunch so events committed while no watcher was '
997
+ + 'attached are printed instead of skipped and an already-reported stall does not fire again. '
998
+ + '`--heartbeat` is opt-in for V2; legacy runs keep the historical heartbeat stream. Distinct from the '
995
999
  + 'full-screen tui and the machine-oriented events replay.',
996
1000
  args: [{ name: '<runId>', desc: 'shortId or runId' }],
997
1001
  options: [
998
1002
  { flag: '--interval <seconds>', desc: 'poll interval while following', default: '2' },
999
- { flag: '--heartbeat <seconds>', desc: 'max gap between heartbeat lines when nothing has changed', default: '60' },
1000
- { flag: '--jsonl', desc: 'emit one JSON object per line instead of human text', default: 'off (human text)' },
1003
+ { flag: '--heartbeat <seconds>', desc: 'print a periodic heartbeat line when nothing has changed; opt-in for V2, must be >= 1', default: 'off for V2, 60 for legacy' },
1004
+ { flag: '--stall-after <seconds>', desc: 'report a running agent as silent after this many seconds without activity; must be >= 1', default: '300' },
1005
+ { flag: '--next', desc: 'print no attach line; exit after the first poll that printed a notable event, or immediately at a pause or terminal status', default: 'off (follows until terminal or pause)' },
1006
+ { flag: '--after <sequence>', desc: 'start from this durable event sequence instead of the current high-water mark, so events committed since the previous watcher exited are printed; use the value from the previous `next:` line (in --jsonl, the `sequence` field of the last object)', default: 'attach at the current high-water mark' },
1007
+ { flag: '--since <iso-timestamp>', desc: 'the previous watcher\'s exit time; a running agent already silent at attach is reported only if its silence crossed --stall-after at or after this time, so no duplicate stall line prints (its recovery still does); use the value from the previous `next:` line', default: 'report every agent silent past --stall-after at attach' },
1008
+ { flag: '--jsonl', desc: 'emit one JSON object per line instead of human text; every object carries the `sequence` it was emitted at, and the `next:` relaunch line is not printed', default: 'off (human text)' },
1001
1009
  { flag: '--once', desc: 'print a single current snapshot and exit immediately instead of following', default: 'off (follows until terminal)' },
1002
- { flag: '--verbose', desc: 'include per-agent action detail lines', default: 'off (compact)' },
1010
+ { flag: '--verbose', desc: 'include started, retry, and steering-delivered lines (V2) and per-agent action detail (legacy)', default: 'off (compact)' },
1003
1011
  ],
1004
1012
  safety: [
1005
1013
  'read-only — polls durable state/events on a timer; writes nothing',
1006
1014
  'exits 0 if the run reaches a delivered status (or on --once), 1 if it reaches a non-delivered terminal status',
1015
+ '--next exits 0 while the run continues or when it delivered, 1 when it ended without delivering or the kernel is not running',
1016
+ 'for a V2 run, a --next exit that leaves the run going ends with `next: bullswarm workflow watch <shortId> --next --after <sequence> --since <iso>`; pause, terminal and interrupted exits keep their own outcome/next lines',
1017
+ ],
1018
+ examples: [
1019
+ { cmd: 'bullswarm workflow watch ab12cd --next', note: 'print the next notable event and exit; relaunch until outcome reports a pause or a terminal status' },
1020
+ { cmd: 'bullswarm workflow watch ab12cd --next --after 42 --since 2026-09-08T10:15:00.000Z', note: 'the relaunch: copy both values from the `next:` line the previous exit printed' },
1021
+ { cmd: 'bullswarm workflow watch ab12cd --stall-after 120 --heartbeat 30' },
1007
1022
  ],
1008
- examples: [{ cmd: 'bullswarm workflow watch ab12cd --heartbeat 30' }],
1009
1023
  next: 'bullswarm workflow runs result <runId> --json once it finishes, or bullswarm workflow tui <runId> for the interactive view.',
1010
1024
  });
1011
1025
 
@@ -1252,23 +1252,56 @@ function wfEvents(opts) {
1252
1252
  }
1253
1253
 
1254
1254
  async function wfWatch(opts) {
1255
+ // A value flag with no value (--heartbeat, --interval, --stall-after,
1256
+ // --after, --since) is a usage error, not a silent fall back to the default.
1257
+ const flagError = flagErrors(opts, ['workflow', 'watch']);
1258
+ if (flagError != null) return flagError;
1255
1259
  const token = opts.rest[0];
1256
1260
  if (!token) {
1257
1261
  console.error(`usage: ${usageLine(['workflow', 'watch'])}`);
1258
1262
  return 2;
1259
1263
  }
1260
1264
  const intervalSec = Number(opts.interval ?? 2);
1261
- const heartbeatSec = Number(opts.heartbeat ?? 60);
1265
+ // --heartbeat is now opt-in: absent means no periodic line for a V2 run and
1266
+ // the historical 60s for a legacy one.
1267
+ const heartbeatSec = opts.heartbeat == null ? null : Number(opts.heartbeat);
1262
1268
  if (!Number.isFinite(intervalSec) || intervalSec < 0.1 ||
1263
- !Number.isFinite(heartbeatSec) || heartbeatSec < 1) {
1269
+ (heartbeatSec != null && (!Number.isFinite(heartbeatSec) || heartbeatSec < 1))) {
1264
1270
  console.error('✗ --interval must be >= 0.1 seconds and --heartbeat must be >= 1 second');
1265
1271
  return 2;
1266
1272
  }
1273
+ const stallAfterSec = Number(opts['stall-after'] ?? 300);
1274
+ if (!Number.isFinite(stallAfterSec) || stallAfterSec < 1) {
1275
+ console.error('✗ --stall-after must be >= 1 second');
1276
+ return 2;
1277
+ }
1278
+ // Continuity for a relaunched watcher: both values come from the `next:` line
1279
+ // the previous --next exit printed.
1280
+ let afterSequence = null;
1281
+ if (opts.after != null) {
1282
+ afterSequence = Number(opts.after);
1283
+ if (!Number.isInteger(afterSequence) || afterSequence < 0) {
1284
+ console.error('✗ --after must be a non-negative integer');
1285
+ return 2;
1286
+ }
1287
+ }
1288
+ let sinceMs = null;
1289
+ if (opts.since != null) {
1290
+ sinceMs = Date.parse(opts.since);
1291
+ if (!Number.isFinite(sinceMs)) {
1292
+ console.error('✗ --since must be an ISO 8601 timestamp');
1293
+ return 2;
1294
+ }
1295
+ }
1267
1296
  try {
1268
1297
  return await runWorkflowWatch(BULLSWARM_DIR(), token, {
1269
1298
  intervalMs: intervalSec * 1000,
1270
- heartbeatMs: heartbeatSec * 1000,
1299
+ heartbeatMs: heartbeatSec == null ? null : heartbeatSec * 1000,
1300
+ stallAfterMs: stallAfterSec * 1000,
1301
+ afterSequence,
1302
+ sinceMs,
1271
1303
  once: opts.once === true,
1304
+ next: opts.next === true,
1272
1305
  jsonl: opts.jsonl === true,
1273
1306
  verbose: opts.verbose === true,
1274
1307
  });
@@ -1375,7 +1408,7 @@ function parseFlags(argv) {
1375
1408
  'worker-pool', 'worker-model', 'request', 'run-id',
1376
1409
  'suggested-plan', 'planner', 'program', 'summary', 'reason',
1377
1410
  'max-agents', 'max-expansion-rounds', 'max-actions', 'concurrency',
1378
- 'retry-attempts', 'interval', 'heartbeat', 'message',
1411
+ 'retry-attempts', 'interval', 'heartbeat', 'stall-after', 'since', 'message',
1379
1412
  ]);
1380
1413
  // A value flag with no value (end of argv, or the next token is another
1381
1414
  // flag) is a usage error, never a silent default: a bare --program must not
@@ -1,7 +1,8 @@
1
1
  import { withV2Cancellation } from './v2-cancellation.js';
2
2
  // Low-noise, non-interactive workflow progress watcher.
3
- // Prints only semantic changes plus a periodic heartbeat, then a timing
4
- // breakdown at terminal status. This is intentionally distinct from the
3
+ // V2 runs are event-based: one attach line, then one line per notable event
4
+ // and silence while work is merely in progress. Legacy runs keep their
5
+ // transition-plus-heartbeat output. This is intentionally distinct from the
5
6
  // full-screen TUI and the machine-oriented events replay API.
6
7
 
7
8
  import { existsSync, readFileSync } from 'node:fs';
@@ -10,6 +11,7 @@ import { resolveRunId, v2RunnerLiveness } from './short-id.js';
10
11
  import { readSteering } from './steering.js';
11
12
  import { hasPassingRequirementEvidence, isProgramWorkflow } from './execution-policy.js';
12
13
  import { readEvents } from './events.js';
14
+ import { presentationStageStatus, projectV2DependencyStages } from './v2-presentation.js';
13
15
  import { isDeliveredWorkflowStatus, isTerminalWorkflowStatus } from './status.js';
14
16
 
15
17
  function readJson(path) {
@@ -283,31 +285,388 @@ async function resolveRunWithGrace(bullswarmDir, token, waitForRunMs, intervalMs
283
285
  }
284
286
  }
285
287
 
288
+ // ── Event mode ──────────────────────────────────────────────────────────────
289
+ // For V2 runs the watcher is event-based: one attach line, then one line per
290
+ // notable event and nothing at all while work is merely in progress. Progress
291
+ // polling still happens (state.json plus events.jsonl), but a poll that
292
+ // carries no notable event prints nothing.
293
+
294
+ export const DEFAULT_STALL_AFTER_MS = 300_000;
295
+
296
+ const V2_SCHEMA = 'bullswarm.workflow.state.v2';
297
+
298
+ function isV2State(state) {
299
+ return state?.schemaVersion === V2_SCHEMA;
300
+ }
301
+
302
+ // Every agent attempt in one list with a stable key, so a silent episode can
303
+ // be reported exactly once and recovered exactly once. Worker attempts carry
304
+ // their own id; planner and scout attempts are keyed by their ordinal.
305
+ function v2AttemptRecords(state) {
306
+ const records = [];
307
+ for (const attempt of state.preflight?.scout?.attempts ?? []) {
308
+ records.push({ key: `preflight-scout-${attempt.ordinal}`, actionId: 'preflight-scout', attempt });
309
+ }
310
+ for (const attempt of state.planner?.attempts ?? []) {
311
+ records.push({ key: `workflow-planner-${attempt.ordinal}`, actionId: 'workflow-planner', attempt });
312
+ }
313
+ for (const attempt of state.attempts ?? []) {
314
+ records.push({ key: attempt.id ?? `${attempt.actionId}-${attempt.ordinal}`, actionId: attempt.actionId, attempt });
315
+ }
316
+ return records;
317
+ }
318
+
319
+ // Epoch ms of the most recent sign of life for one attempt: output bytes, a
320
+ // provider stream event, or (before either) its start.
321
+ function attemptActivityAt(attempt) {
322
+ const latest = Math.max(
323
+ Date.parse(attempt.lastActivityAt ?? '') || 0,
324
+ Date.parse(attempt.lastEventAt ?? '') || 0,
325
+ Date.parse(attempt.startedAt ?? '') || 0,
326
+ );
327
+ return latest || null;
328
+ }
329
+
330
+ // Program runs group actions into dependency levels projected from the program
331
+ // itself; verified runs carry durable presentation stages. Both are rendered
332
+ // as one "stage completed" line, and each stage is reported once.
333
+ function v2Stages(state) {
334
+ const stages = isProgramWorkflow(state)
335
+ ? projectV2DependencyStages(state)
336
+ : state.presentation?.stages ?? [];
337
+ return stages.map((stage) => ({ stage, status: presentationStageStatus(stage, state.actions ?? []) }));
338
+ }
339
+
340
+ function actionDurationSec(runtime, event, nowMs) {
341
+ if (!runtime?.startedAt) return null;
342
+ return secondsBetween(runtime.startedAt, runtime.finishedAt ?? event?.committedAt ?? new Date(nowMs).toISOString());
343
+ }
344
+
345
+ function attemptOrdinal(state, attemptId) {
346
+ const known = (state.attempts ?? []).find((attempt) => attempt.id === attemptId)?.ordinal;
347
+ if (Number.isFinite(known)) return known;
348
+ const trailing = Number(/-(\d+)$/.exec(String(attemptId ?? ''))?.[1]);
349
+ return Number.isFinite(trailing) ? trailing : null;
350
+ }
351
+
352
+ // Carried across polls: which stages have already been reported, which
353
+ // attempts are in a reported silent episode, and which actions have a failed
354
+ // attempt whose replacement would be a mechanical retry.
355
+ //
356
+ // A relaunched watcher (--after/--since) seeds the same memory from continuity
357
+ // inputs instead of from the live state alone: replayedEvents are the durable
358
+ // events this launch is about to print, and sinceMs is when the previous
359
+ // watcher exited.
360
+ export function initialWatchMemory(state, {
361
+ replayedEvents = [],
362
+ sinceMs = null,
363
+ stallAfterMs = DEFAULT_STALL_AFTER_MS,
364
+ nowMs = Date.now(),
365
+ } = {}) {
366
+ // A stage whose action finished among the replayed events is exactly the news
367
+ // this relaunch exists to deliver, so it must not be pre-marked as reported
368
+ // even though it is already terminal on disk. A stage that was terminal
369
+ // before the cursor stays silent.
370
+ const replayedActions = new Set(replayedEvents
371
+ .filter((event) => event.type === 'action.finished' || event.type === 'evidence.recorded')
372
+ .map((event) => event.payload?.actionId)
373
+ .filter(Boolean));
374
+ const done = isV2State(state)
375
+ ? v2Stages(state)
376
+ .filter(({ stage, status }) => (status.terminal || stage.completedAt)
377
+ && !(stage.actionIds ?? []).some((id) => replayedActions.has(id)))
378
+ .map(({ stage }) => stage.id)
379
+ : [];
380
+ const stalled = new Map();
381
+ // An agent whose silence crossed the stall threshold before the previous
382
+ // watcher exited was already reported by it: remember the episode so this
383
+ // launch prints no duplicate stall line, while its recovery still prints.
384
+ if (sinceMs != null && isV2State(state)) {
385
+ for (const { key, attempt } of v2AttemptRecords(state)) {
386
+ if (attempt.status !== 'running') continue;
387
+ const activityAt = attemptActivityAt(attempt);
388
+ if (activityAt == null) continue;
389
+ if (nowMs - activityAt < stallAfterMs) continue;
390
+ if (activityAt + stallAfterMs >= sinceMs) continue;
391
+ stalled.set(key, { since: activityAt });
392
+ }
393
+ }
394
+ return { stages: new Set(done), stalled, retry: new Map() };
395
+ }
396
+
397
+ /**
398
+ * Turn the durable events committed since the last poll, plus the current
399
+ * state, into the notable events a watcher should print. Pure: the carried
400
+ * memory is never mutated, a fresh one is returned alongside the events.
401
+ */
402
+ export function notableWatchEvents({
403
+ events = [],
404
+ state,
405
+ memory = null,
406
+ verbose = false,
407
+ nowMs = Date.now(),
408
+ stallAfterMs = DEFAULT_STALL_AFTER_MS,
409
+ } = {}) {
410
+ const carried = memory ?? initialWatchMemory(state);
411
+ const stages = new Set(carried.stages);
412
+ const stalled = new Map(carried.stalled);
413
+ const retry = new Map(carried.retry);
414
+ const notable = [];
415
+
416
+ const onAttemptStarted = (actionId, payload, ordinal) => {
417
+ // A new attempt after a failed one is the dispatcher retrying mechanically.
418
+ if (retry.has(actionId)) {
419
+ if (verbose) notable.push({ type: 'attempt.retrying', actionId, failureKind: retry.get(actionId) });
420
+ retry.delete(actionId);
421
+ }
422
+ if (verbose) {
423
+ notable.push({
424
+ type: 'action.started', actionId,
425
+ pool: payload.pool ?? null, model: payload.model ?? null, attempt: ordinal,
426
+ });
427
+ }
428
+ };
429
+ const onAttemptFinished = (actionId, payload) => {
430
+ if (payload.status === 'succeeded') retry.delete(actionId);
431
+ else retry.set(actionId, payload.failureKind ?? payload.status ?? 'unknown');
432
+ };
433
+
434
+ for (const event of events) {
435
+ const payload = event.payload ?? {};
436
+ switch (event.type) {
437
+ case 'action.finished': {
438
+ const runtime = (state.actions ?? []).find((item) => item.id === payload.actionId) ?? null;
439
+ const status = payload.status ?? runtime?.status ?? 'finished';
440
+ retry.delete(payload.actionId);
441
+ notable.push({
442
+ type: 'action.finished',
443
+ actionId: payload.actionId,
444
+ status,
445
+ failureKind: payload.failureKind ?? runtime?.lastFailure?.kind ?? null,
446
+ why: payload.why ?? runtime?.lastFailure?.message
447
+ ?? (payload.outOfScope ?? payload.paths)?.join(', ') ?? null,
448
+ durationSec: status === 'blocked' ? null : actionDurationSec(runtime, event, nowMs),
449
+ });
450
+ break;
451
+ }
452
+ case 'evidence.recorded': {
453
+ const requirementIds = payload.requirements ?? [];
454
+ notable.push({
455
+ type: 'evidence.recorded',
456
+ actionId: payload.actionId,
457
+ requirements: requirementIds.map((id) => ({
458
+ id,
459
+ status: payload.statuses?.[id] ?? state.ledger?.requirements?.[id]?.status ?? 'unknown',
460
+ })),
461
+ });
462
+ break;
463
+ }
464
+ case 'presentation.stage_completed': {
465
+ if (stages.has(payload.stageId)) break;
466
+ stages.add(payload.stageId);
467
+ notable.push({
468
+ type: 'stage.completed',
469
+ stageId: payload.stageId ?? null,
470
+ label: payload.label ?? payload.stageId ?? 'stage',
471
+ status: payload.status === 'completed' ? 'completed' : 'ended',
472
+ completed: payload.completed ?? null,
473
+ total: payload.total ?? null,
474
+ });
475
+ break;
476
+ }
477
+ case 'planner.finished': {
478
+ notable.push(payload.ok === false
479
+ ? {
480
+ type: 'planner.finished', ok: false, turn: payload.turn ?? null,
481
+ failureKind: payload.failureKind ?? null,
482
+ why: payload.why ?? payload.failureKind ?? 'no reason recorded',
483
+ }
484
+ : {
485
+ type: 'planner.finished', ok: true, turn: payload.turn ?? null,
486
+ kind: payload.kind ?? null, summary: payload.summary ?? null,
487
+ });
488
+ break;
489
+ }
490
+ case 'workflow.cancellation_requested':
491
+ notable.push({
492
+ type: 'cancellation.requested',
493
+ reason: payload.reason ?? null,
494
+ source: payload.source ?? null,
495
+ });
496
+ break;
497
+ case 'attempt.started':
498
+ onAttemptStarted(payload.actionId, payload, attemptOrdinal(state, payload.attemptId));
499
+ break;
500
+ case 'planner.attempt_started':
501
+ onAttemptStarted('workflow-planner', payload, payload.ordinal ?? null);
502
+ break;
503
+ case 'preflight.scout_attempt_started':
504
+ onAttemptStarted('preflight-scout', payload, payload.ordinal ?? null);
505
+ break;
506
+ case 'attempt.finished':
507
+ onAttemptFinished(payload.actionId, payload);
508
+ break;
509
+ case 'planner.attempt_finished':
510
+ onAttemptFinished('workflow-planner', payload);
511
+ break;
512
+ case 'preflight.scout_attempt_finished':
513
+ onAttemptFinished('preflight-scout', payload);
514
+ break;
515
+ case 'steering.delivered':
516
+ if (verbose) notable.push({ type: 'steering.delivered', steeringId: payload.steeringId ?? null });
517
+ break;
518
+ default:
519
+ break;
520
+ }
521
+ }
522
+
523
+ // Program runs commit no durable level-completed event, so a dependency level
524
+ // that flipped to terminal between polls is detected here instead. A stage
525
+ // already reported from its durable event is never reported twice.
526
+ if (isProgramWorkflow(state)) {
527
+ for (const { stage, status } of v2Stages(state)) {
528
+ if (!status.terminal || stages.has(stage.id)) continue;
529
+ stages.add(stage.id);
530
+ notable.push({
531
+ type: 'stage.completed',
532
+ stageId: stage.id,
533
+ label: stage.label,
534
+ status: status.successful ? 'completed' : 'ended',
535
+ completed: status.completed,
536
+ total: status.total,
537
+ });
538
+ }
539
+ }
540
+
541
+ // Silence is measured from the later of the attempt's last output activity,
542
+ // last agent event and start. A stalled agent is never killed; the line only
543
+ // says the watcher can no longer see progress.
544
+ for (const { key, actionId, attempt } of v2AttemptRecords(state)) {
545
+ const activityAt = attemptActivityAt(attempt);
546
+ if (attempt.status !== 'running' || activityAt == null) {
547
+ stalled.delete(key);
548
+ continue;
549
+ }
550
+ const episode = stalled.get(key);
551
+ if (episode) {
552
+ if (activityAt > episode.since) {
553
+ stalled.delete(key);
554
+ notable.push({
555
+ type: 'agent.recovered', actionId, attemptId: key,
556
+ silentSec: Math.max(0, Math.round((activityAt - episode.since) / 1000)),
557
+ });
558
+ }
559
+ continue;
560
+ }
561
+ const silentMs = Math.max(0, nowMs - activityAt);
562
+ if (silentMs < stallAfterMs) continue;
563
+ stalled.set(key, { since: activityAt });
564
+ notable.push({
565
+ type: 'agent.stalled', actionId, attemptId: key,
566
+ silentSec: Math.round(silentMs / 1000),
567
+ pool: attempt.pool ?? null, model: attempt.model ?? null,
568
+ });
569
+ }
570
+
571
+ return { notable, memory: { stages, stalled, retry } };
572
+ }
573
+
574
+ /** One notable event as one human line. */
575
+ export function renderWatchEvent(event) {
576
+ switch (event.type) {
577
+ case 'attach':
578
+ return `● watching ${event.shortId ?? event.runId} · ${event.status} · ` +
579
+ `${event.running} running, ${event.waiting} waiting · +${formatDuration(event.elapsedSec)}`;
580
+ case 'action.finished':
581
+ if (event.status === 'succeeded') return `✓ ${event.actionId} finished · ${formatDuration(event.durationSec)}`;
582
+ if (event.status === 'blocked') return `⊘ ${event.actionId} blocked · ${event.why ?? 'dependency not satisfied'}`;
583
+ if (event.status === 'cancelled') return `✗ ${event.actionId} cancelled · ${formatDuration(event.durationSec)}`;
584
+ return `✗ ${event.actionId} ${event.status} · ${event.failureKind ?? 'unknown'}: ` +
585
+ `${event.why ?? 'no reason recorded'} · ${formatDuration(event.durationSec)}`;
586
+ case 'evidence.recorded':
587
+ return `◆ ${event.actionId} evidence · ` +
588
+ (event.requirements.map((item) => `${item.id} ${item.status}`).join(', ') || 'no requirements');
589
+ case 'stage.completed':
590
+ return event.status === 'completed'
591
+ ? `✓ ${event.label} completed · ${event.completed}/${event.total}`
592
+ : `✗ ${event.label} ended · ${event.completed}/${event.total}`;
593
+ case 'planner.finished':
594
+ if (!event.ok) return `× planning attempt rejected · ${event.why}`;
595
+ return event.turn === 1
596
+ ? `◇ plan created (turn 1) · ${event.summary ?? event.kind ?? 'no summary'}`
597
+ : `◇ plan updated #${event.turn} · ${event.summary ?? event.kind ?? 'no summary'}`;
598
+ case 'agent.stalled':
599
+ return `⚠ ${event.actionId} silent for ${formatDuration(event.silentSec)} · ` +
600
+ `${event.pool ?? '?'}/${event.model ?? '?'} · still running, not auto-killed`;
601
+ case 'agent.recovered':
602
+ return `↻ ${event.actionId} active again after ${formatDuration(event.silentSec)}`;
603
+ case 'cancellation.requested':
604
+ return '⧖ cancellation requested';
605
+ case 'action.started':
606
+ return `▶ ${event.actionId} started · ${event.pool ?? '?'}/${event.model ?? '?'} · attempt ${event.attempt ?? '?'}`;
607
+ case 'attempt.retrying':
608
+ return `↺ ${event.actionId} retrying · ${event.failureKind}`;
609
+ case 'steering.delivered':
610
+ return '→ steering delivered';
611
+ default:
612
+ return null;
613
+ }
614
+ }
615
+
616
+ function watchEventLine(event, { jsonl, at, runId, shortId, sequence = null }) {
617
+ if (!jsonl) return renderWatchEvent(event);
618
+ const { type, ...fields } = event;
619
+ // `sequence` is the durable cursor this object was emitted at: the machine
620
+ // form of the human `next: ... --after <sequence>` relaunch line.
621
+ return JSON.stringify({ type, at, runId, shortId, ...(sequence == null ? {} : { sequence }), ...fields });
622
+ }
623
+
286
624
  export async function runWorkflowWatch(bullswarmDir, token, {
287
625
  intervalMs = 2000,
288
- heartbeatMs = 60000,
626
+ // Absent means "no periodic heartbeat" for V2 runs and the historical 60s
627
+ // for legacy runs; `--heartbeat <seconds>` opts a V2 run back in.
628
+ heartbeatMs = null,
289
629
  once = false,
630
+ next = false,
290
631
  jsonl = false,
291
632
  verbose = false,
633
+ stallAfterMs = DEFAULT_STALL_AFTER_MS,
634
+ // Continuity across a --next relaunch: start from the durable sequence the
635
+ // previous watcher had consumed, and treat stalls it already reported (it
636
+ // exited at sinceMs) as reported. Both absent means attach as usual.
637
+ afterSequence = null,
638
+ sinceMs = null,
292
639
  waitForRunMs = 0,
640
+ now = Date.now,
293
641
  output = process.stdout,
294
642
  } = {}) {
295
643
  const resolved = await resolveRunWithGrace(bullswarmDir, token, waitForRunMs, intervalMs);
296
644
  const statePath = join(resolved.runDir, 'state.json');
645
+ // --next follows the run until something happens, so it never degrades to a
646
+ // single snapshot even if --once is also passed.
647
+ const oneShot = once && !next;
297
648
  let priorFingerprint = null;
298
649
  let priorHumanFingerprint = null;
299
650
  let lastPrintedAt = 0;
300
651
  let priorSequence = null;
301
652
  let pendingEvents = [];
302
653
  let lastActivityAt = null;
654
+ let memory = null;
655
+ let attached = false;
303
656
  while (true) {
304
657
  const state = withV2Cancellation(readJson(statePath), resolved.runDir);
305
658
  if (state) {
306
- const snapshot = watchSnapshot(resolved.runDir, state);
659
+ const nowMs = now();
660
+ const snapshot = watchSnapshot(resolved.runDir, state, new Date(nowMs));
661
+ // Legacy runs keep their transition-plus-heartbeat output exactly as it
662
+ // was; only V2 runs become event-based, and --once stays a snapshot.
663
+ const eventMode = isV2State(state) && !oneShot;
307
664
  if (priorSequence == null) {
308
665
  // A newly attached watcher has no preceding interval. Start at the
309
- // durable high-water mark instead of replaying the run lifetime.
310
- priorSequence = state.events?.sequence ?? state.eventSequence ?? 0;
666
+ // durable high-water mark instead of replaying the run lifetime, unless
667
+ // --after names the cursor the previous watcher stopped at, in which
668
+ // case the events committed since then are replayed and printed.
669
+ priorSequence = afterSequence ?? state.events?.sequence ?? state.eventSequence ?? 0;
311
670
  // Semantic quiet counts durable marks only (events, action starts and
312
671
  // finishes). Raw child output is surfaced separately as transport
313
672
  // liveness so a thinking agent and a dead one look different.
@@ -322,7 +681,7 @@ export async function runWorkflowWatch(bullswarmDir, token, {
322
681
  Date.parse(agent.lastActionAt ?? '') || 0,
323
682
  Date.parse(agent.startedAt ?? '') || 0,
324
683
  )),
325
- Date.parse(state.lifecycle?.startedAt ?? state.startedAt ?? '') || Date.now(),
684
+ Date.parse(state.lifecycle?.startedAt ?? state.startedAt ?? '') || nowMs,
326
685
  );
327
686
  }
328
687
  const newEvents = readEvents(resolved.runDir, { after: priorSequence });
@@ -330,51 +689,117 @@ export async function runWorkflowWatch(bullswarmDir, token, {
330
689
  pendingEvents.push(...newEvents);
331
690
  lastActivityAt = Math.max(
332
691
  lastActivityAt,
333
- ...newEvents.map((event) => Date.parse(event.committedAt ?? '') || Date.now()),
692
+ ...newEvents.map((event) => Date.parse(event.committedAt ?? '') || nowMs),
334
693
  );
335
694
  priorSequence = newEvents.at(-1)?.sequence ?? state.events?.sequence ?? state.eventSequence ?? priorSequence;
336
695
  }
337
- snapshot.quietForSec = Math.max(0, Math.floor((Date.now() - lastActivityAt) / 1000));
338
- const fingerprint = snapshotFingerprint(snapshot);
339
- const humanFingerprint = humanTransitionFingerprint(snapshot);
340
- const heartbeat = Date.now() - lastPrintedAt >= heartbeatMs;
341
- const changed = jsonl || verbose
342
- ? fingerprint !== priorFingerprint
343
- : humanFingerprint !== priorHumanFingerprint;
344
- if (changed || heartbeat || once) {
345
- output.write(jsonl
346
- ? `${JSON.stringify({ type: heartbeat && fingerprint === priorFingerprint ? 'heartbeat' : 'progress', ...snapshot })}\n`
347
- : `${renderWatchSnapshot(snapshot, {
348
- heartbeat: heartbeat && !changed,
349
- verbose,
350
- events: pendingEvents,
351
- })}\n`);
352
- priorFingerprint = fingerprint;
353
- priorHumanFingerprint = humanFingerprint;
354
- lastPrintedAt = Date.now();
355
- pendingEvents = [];
696
+ snapshot.quietForSec = Math.max(0, Math.floor((nowMs - lastActivityAt) / 1000));
697
+ const emitLine = (event) => {
698
+ const line = watchEventLine(event, {
699
+ jsonl, at: snapshot.at, runId: snapshot.runId, shortId: snapshot.shortId,
700
+ sequence: priorSequence,
701
+ });
702
+ if (line == null) return;
703
+ output.write(`${line}\n`);
704
+ lastPrintedAt = nowMs;
705
+ };
706
+ let notablePrinted = 0;
707
+ if (eventMode) {
708
+ if (!attached) {
709
+ attached = true;
710
+ memory = initialWatchMemory(state, {
711
+ replayedEvents: afterSequence == null ? [] : newEvents,
712
+ sinceMs, stallAfterMs, nowMs,
713
+ });
714
+ lastPrintedAt = nowMs;
715
+ // --next is a wake-up call, not a follow: it prints only what happens.
716
+ if (!next) {
717
+ emitLine({
718
+ type: 'attach', runId: snapshot.runId, shortId: snapshot.shortId,
719
+ status: snapshot.status, running: snapshot.runningCount,
720
+ waiting: snapshot.waitingCount, elapsedSec: snapshot.elapsedSec,
721
+ });
722
+ }
723
+ }
724
+ const collected = notableWatchEvents({
725
+ events: newEvents, state, memory, verbose, nowMs, stallAfterMs,
726
+ });
727
+ memory = collected.memory;
728
+ for (const event of collected.notable) {
729
+ emitLine(event);
730
+ notablePrinted += 1;
731
+ }
732
+ // The periodic heartbeat is opt-in for V2 runs (--heartbeat <seconds>).
733
+ const beatMs = Number.isFinite(heartbeatMs) && heartbeatMs > 0 ? heartbeatMs : null;
734
+ if (beatMs != null && !next && nowMs - lastPrintedAt >= beatMs) {
735
+ output.write(jsonl
736
+ ? `${JSON.stringify({ type: 'heartbeat', ...snapshot })}\n`
737
+ : `${renderWatchSnapshot(snapshot, { heartbeat: true, verbose, events: pendingEvents })}\n`);
738
+ lastPrintedAt = nowMs;
739
+ pendingEvents = [];
740
+ }
741
+ } else {
742
+ const legacyHeartbeatMs = heartbeatMs == null ? 60000 : heartbeatMs;
743
+ const fingerprint = snapshotFingerprint(snapshot);
744
+ const humanFingerprint = humanTransitionFingerprint(snapshot);
745
+ const heartbeat = nowMs - lastPrintedAt >= legacyHeartbeatMs;
746
+ const changed = jsonl || verbose
747
+ ? fingerprint !== priorFingerprint
748
+ : humanFingerprint !== priorHumanFingerprint;
749
+ if (changed || heartbeat || oneShot) {
750
+ output.write(jsonl
751
+ ? `${JSON.stringify({ type: heartbeat && fingerprint === priorFingerprint ? 'heartbeat' : 'progress', ...snapshot })}\n`
752
+ : `${renderWatchSnapshot(snapshot, {
753
+ heartbeat: heartbeat && !changed,
754
+ verbose,
755
+ events: pendingEvents,
756
+ })}\n`);
757
+ priorFingerprint = fingerprint;
758
+ priorHumanFingerprint = humanFingerprint;
759
+ lastPrintedAt = nowMs;
760
+ pendingEvents = [];
761
+ if (changed) notablePrinted += 1;
762
+ }
356
763
  }
357
764
  if (snapshot.interrupted) {
358
- if (!jsonl) { output.write(`outcome: interrupted; edits retained\nnext: bullswarm workflow resume ${snapshot.shortId ?? snapshot.runId}\n`); }
359
- return once ? 0 : 1;
765
+ if (eventMode && jsonl) emitLine({ type: 'interrupted', status: snapshot.status });
766
+ else if (!jsonl) { output.write(`outcome: interrupted; edits retained\nnext: bullswarm workflow resume ${snapshot.shortId ?? snapshot.runId}\n`); }
767
+ return oneShot ? 0 : 1;
360
768
  }
361
- if (snapshot.terminal || once) {
362
- if (!jsonl && snapshot.terminal) {
769
+ if (snapshot.terminal || oneShot) {
770
+ if (eventMode && jsonl && snapshot.terminal) {
771
+ emitLine({ type: 'finished', status: snapshot.status, delivered: isDeliveredWorkflowStatus(snapshot.status) });
772
+ } else if (!jsonl && snapshot.terminal) {
363
773
  output.write(`outcome: ${snapshot.status}\n`);
364
774
  output.write(`next: bullswarm workflow runs result ${snapshot.shortId ?? snapshot.runId} --json\n`);
365
775
  }
366
- return isDeliveredWorkflowStatus(snapshot.status) || once ? 0 : 1;
776
+ return isDeliveredWorkflowStatus(snapshot.status) || oneShot ? 0 : 1;
367
777
  }
368
778
  if (snapshot.awaitingPlanner) {
369
779
  // A caller-planner run has paused durably; the runtime process has
370
780
  // exited and nothing will change until the caller submits a program
371
781
  // (or, after a cancellation request, resumes it once to finalize).
372
- if (!jsonl) {
373
- const token = snapshot.shortId ?? snapshot.runId;
782
+ const runToken = snapshot.shortId ?? snapshot.runId;
783
+ if (eventMode && jsonl) {
784
+ emitLine({
785
+ type: 'paused', boundary: snapshot.awaitingPlanner.boundary,
786
+ turn: snapshot.awaitingPlanner.turn, cancellationRequested: snapshot.cancellationRequested,
787
+ });
788
+ } else if (!jsonl) {
374
789
  output.write(`outcome: waiting for the caller planner (${snapshot.awaitingPlanner.boundary} boundary)\n`);
375
790
  output.write(snapshot.cancellationRequested
376
- ? `next: cancellation requested; bullswarm workflow cancel ${token} --json finalizes it\n`
377
- : `next: bullswarm workflow plan show ${token} --json\n`);
791
+ ? `next: cancellation requested; bullswarm workflow cancel ${runToken} --json finalizes it\n`
792
+ : `next: bullswarm workflow plan show ${runToken} --json\n`);
793
+ }
794
+ return 0;
795
+ }
796
+ // --next has delivered its wake-up: something notable happened and the
797
+ // run is still going. The relaunch line hands the caller the exact cursor
798
+ // and exit time to resume from, so nothing committed in between is lost.
799
+ if (next && notablePrinted > 0) {
800
+ if (eventMode && !jsonl) {
801
+ output.write(`next: bullswarm workflow watch ${snapshot.shortId ?? snapshot.runId}`
802
+ + ` --next --after ${priorSequence} --since ${snapshot.at}\n`);
378
803
  }
379
804
  return 0;
380
805
  }