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 +22 -0
- package/README.md +38 -11
- package/package.json +1 -1
- package/skill/SKILL.md +12 -1
- package/skill/references/operations.md +15 -1
- package/src/help.js +23 -9
- package/src/workflow/cli.js +37 -4
- package/src/workflow/watch-cli.js +461 -36
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> #
|
|
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.
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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> --
|
|
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 #
|
|
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
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
|
-
|
|
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
|
|
992
|
-
+ '
|
|
993
|
-
+ '
|
|
994
|
-
+ '
|
|
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: '
|
|
1000
|
-
{ flag: '--
|
|
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
|
|
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
|
|
package/src/workflow/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ?? '') ||
|
|
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 ?? '') ||
|
|
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((
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 (
|
|
359
|
-
|
|
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 ||
|
|
362
|
-
if (
|
|
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) ||
|
|
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
|
-
|
|
373
|
-
|
|
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 ${
|
|
377
|
-
: `next: bullswarm workflow plan show ${
|
|
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
|
}
|