diff --git a/CHANGELOG.md b/CHANGELOG.md index f90fd88..b137c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ Notable changes to the Swarm plugin. Format follows agent) as settled, instead of refusing cleanup until its tab is focused. ### Added +- `broadcast` harvest verb: types one single-line message into every running + slot's agent (or `HERDR_SWARM_TARGETS`) and submits it. A slot is only typed + into when its pane still holds the slot's terminal and a program, not the + shell, is in the foreground. Multi-line text, control characters, and a + leading `-` are refused. +- Elapsed time per slot in the status pane and the compare view (fan-out now + records `started_at`). Token and cost display is deferred: Herdr has no + usage channel and agents do not report usage to it. - Per-slot tasks without a TTY: `` marker lines split `HERDR_SWARM_TASK_FILE` into per-slot sections after a shared preamble. The highest section sets the slot count when `HERDR_SWARM_SLOTS` is unset, diff --git a/README.md b/README.md index 80b99b8..b0d52e7 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,60 @@ stdout and stderr are logged there **verbatim and indefinitely** — the state dir is `0700`, but don't `echo`/`set -x` secrets in `setup.sh`: a token printed during dependency install stays on disk until you delete the log. +## Steering a running swarm + +Send one message to every running slot's agent. It is typed in and submitted, +as if you had typed it into each agent: + +```sh +HERDR_SWARM_MESSAGE="Also add tests for the edge cases, then commit." \ + bash scripts/harvest-step.sh broadcast +``` + +- `HERDR_SWARM_MESSAGE_FILE` takes the message from a file instead. +- `HERDR_SWARM_TARGETS=1,3` limits it to some slots. +- Output: one record per targeted slot: + - `broadcast_sentslot` + - `broadcast_skippedslotwhy` + - `broadcast_partialslotwhy`: the text was typed but **not + submitted**. Clear that pane's input line rather than re-running. +- The verb exits 36 unless every target received the message. Targets are + slot numbers, each named at most once. + +**One line of plain text.** A newline inside the text reaches the agent as a +real line break, so a terminal agent would submit half the message +(live-verified). Control characters are keystrokes (Ctrl-C, escape sequences), +not text. Swarm refuses both, plus Unicode line separators, +direction overrides, invalid UTF-8, and a leading `-` (which the Herdr CLI +would parse as a flag). This check doesn't depend on your locale. For longer +instructions, write a file and broadcast "read NOTES.md and follow it". + +**Only into the slot's own agent.** Typing into the wrong program can *run* +the message: a shell executes it, `less` runs `!…`, `vim` treats it as +commands, and an approval dialog takes letters as choices and Enter as "yes". +Right before typing, a slot must pass all of these: + +- its pane still holds the slot's own terminal; +- Herdr doesn't report the agent `blocked`; +- the foreground process group's **leader** is the slot's own agent program + (recorded at fan-out as `agent_command`, e.g. `claude`, or `codex` when + launched as `node …/codex`), not the shell or anything started in the pane; +- the agent hasn't finished by exiting. + +The program is checked again between typing and Enter. If it changed, Enter is +withheld, because unsubmitted text is inert. Rows from runs started before this +version have no `agent_command` and are skipped. On Herdr 0.7.5+ Swarm reports +the agent's state itself, so a `blocked` approval prompt isn't visible. Don't +broadcast while an agent may be asking for approval. Herdr's `agent prompt` +isn't used, because on 0.7.5+ it refuses panes where Swarm reports the agent +state (live-verified: `agent_not_ready`). + +**Elapsed time** per slot shows in the status pane's `time` column and the +compare view. It runs from when the slot started to when finish detection +recorded it done, or to now. Token usage and cost aren't shown: Herdr has no +channel that carries them (its pane `tokens` are display metadata), and no +agent reports them to it. + ## Comparing slots and picking a winner In the harvest pane, `w` opens the compare view, which replaces the slot list diff --git a/bin/renderer-harvest.mjs b/bin/renderer-harvest.mjs index 70e1fc1..b44fcc9 100644 --- a/bin/renderer-harvest.mjs +++ b/bin/renderer-harvest.mjs @@ -13,6 +13,7 @@ import { sanitizeText, manifestPath, parseManifest, + formatDuration, } from "./renderer-shared.mjs"; // --- harvest mode (U6) ------------------------------------------------------- @@ -95,7 +96,7 @@ export function gitDisplayEnv(env) { // Every field is manifest- or git-derived text, so all of it is sanitized. export function renderCompare(view, cols = 80) { const lines = [ - ` ${pad("rank", 5)}${pad("#", 3)}${pad("checks", 16)}${pad("commits", 8)}${pad("files", 6)}${pad("+/-", 12)}${pad("dirty", 6)}${pad("finished", 9)}label`.slice(0, cols), + ` ${pad("rank", 5)}${pad("#", 3)}${pad("checks", 16)}${pad("commits", 8)}${pad("files", 6)}${pad("+/-", 12)}${pad("dirty", 6)}${pad("finished", 9)}${pad("time", 7)}label`.slice(0, cols), ]; if (!view.slots?.length) lines.push(" (no slots to compare)"); for (const s of view.slots ?? []) { @@ -108,7 +109,7 @@ export function renderCompare(view, cols = 80) { )}${pad(s.insertions == null ? "-" : `+${s.insertions}/-${s.deletions}`, 12)}${pad(s.dirty ?? "-", 6)}${pad( sanitizeText(s.finished ?? (s.candidate ? "no" : s.status ?? "")), 9, - )}${sanitizeText(s.label ?? "")}`.slice(0, cols); + )}${pad(formatDuration(Number.isSafeInteger(s.elapsed_seconds) ? s.elapsed_seconds : null), 7)}${sanitizeText(s.label ?? "")}`.slice(0, cols); // Failed checks are the one thing that must not be missed when picking. lines.push(v.status === "failed" ? `${ESC}[7m${line}${ESC}[0m` : line); } diff --git a/bin/renderer-shared.mjs b/bin/renderer-shared.mjs index 8217673..f5bd965 100644 --- a/bin/renderer-shared.mjs +++ b/bin/renderer-shared.mjs @@ -65,7 +65,27 @@ export const TERMINAL_STATUSES = new Set(["archived", "merged", "skipped", "fail // // agents: array from the agent-list query, or null when it was unanswerable. // gitFacts: { [slot]: { worktreeMissing, branchMissing, committed, uncommitted } } -export function reconcileSlots(manifestSlots, agents, gitFacts) { +// Elapsed time for a slot: from its recorded start (started_at, written when +// fan-out marks it running; older rows fall back to the run's created_at) to +// its finish (finished.at from settle), or to `now` while it is still going. +// null when no valid start is known — rendered "-", never guessed. +export function slotElapsedSeconds(row, runCreatedAt, now = Date.now()) { + const start = Date.parse(row?.started_at ?? runCreatedAt ?? ""); + if (!Number.isFinite(start)) return null; + const finished = Date.parse(row?.finished?.at ?? ""); + const end = Number.isFinite(finished) ? finished : now; + return Math.max(0, Math.round((end - start) / 1000)); +} + +export function formatDuration(seconds) { + if (seconds == null) return "-"; + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`; +} + +export function reconcileSlots(manifestSlots, agents, gitFacts, runCreatedAt = null, now = Date.now()) { return (manifestSlots || []).map((row) => { const facts = (gitFacts && gitFacts[row.slot]) || {}; let state; @@ -108,6 +128,8 @@ export function reconcileSlots(manifestSlots, agents, gitFacts) { agent_name: row.agent_name, state, finished: row.finished ?? null, + // Settled history (merged, skipped…) has no live clock to show. + elapsed: TERMINAL_STATUSES.has(row.status) && !row.finished ? null : slotElapsedSeconds(row, runCreatedAt, now), committed: facts.committed ?? null, uncommitted: facts.uncommitted ?? null, }; diff --git a/bin/renderer-status.mjs b/bin/renderer-status.mjs index 4a7d2cc..0495306 100644 --- a/bin/renderer-status.mjs +++ b/bin/renderer-status.mjs @@ -17,6 +17,7 @@ import { parseDiffStat, parseStatusPorcelain, pollDelay, + formatDuration, } from "./renderer-shared.mjs"; const pExecFile = promisify(execFile); @@ -26,7 +27,7 @@ const pExecFile = promisify(execFile); // inverse+red — R5 says loud, and color alone is invisible on some themes, // so inverse carries the weight even without color support. export function renderStatus(rows, cols = 80) { - const head = ` ${pad("#", 3)}${pad("state", 9)}${pad("c/u", 8)}${pad("label", 14)}${pad("branch", 28)}path`; + const head = ` ${pad("#", 3)}${pad("state", 9)}${pad("time", 7)}${pad("c/u", 8)}${pad("label", 14)}${pad("branch", 28)}path`; const lines = [head.slice(0, cols)]; if (rows.length === 0) lines.push(" (no slots in this run)"); for (const r of rows) { @@ -34,7 +35,7 @@ export function renderStatus(rows, cols = 80) { // state comes from herdr's agent_status — externally controlled text on // the same footing as a branch name, so it sanitizes like one. const line = - ` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(counts, 8)}${pad( + ` ${pad(r.slot, 3)}${pad(sanitizeText(r.state ?? ""), 9)}${pad(formatDuration(r.elapsed ?? null), 7)}${pad(counts, 8)}${pad( sanitizeText(r.label ?? ""), 14, )}${pad(sanitizeText(r.branch ?? ""), 28)}${sanitizeText(r.path ?? "-")}`.slice( @@ -247,7 +248,7 @@ export class Renderer { m.slots.forEach((row, i) => { gitFacts[row.slot] = factsList[i]; }); - this.rows = sortSlots(reconcileSlots(m.slots, agents, gitFacts)); + this.rows = sortSlots(reconcileSlots(m.slots, agents, gitFacts, m.created_at)); this.banner = agents === null ? "agent list unavailable — states shown as unknown" : ""; this.paint(); diff --git a/docs/residual-review-findings/broadcast-2026-10-04.md b/docs/residual-review-findings/broadcast-2026-10-04.md new file mode 100644 index 0000000..12b3061 --- /dev/null +++ b/docs/residual-review-findings/broadcast-2026-10-04.md @@ -0,0 +1,58 @@ +# Residual review findings — broadcast and elapsed time, accepted 2026-10-04 + +From an adversarial review of the uncommitted broadcast change: six +findings, two high. The reviewer found no data or git risk, and found the +elapsed-time change safe (no manifest validator rejects `started_at`; old +manifests fall back to `created_at`). Fixed before commit: + +- **High — the message could reach a program other than the agent.** "Busy" + only meant "not the shell", so a `bash`, `less`, or `vim` started in a + finished slot's pane would receive it. A slot now receives it only when the + foreground group's *leader* is its recorded agent program, and slots + finished as `exited` are skipped. Mutation-checked: matching any group + member instead of the leader fails the test. +- **High — keystrokes into an approval prompt.** A slot Herdr reports as + `blocked` is skipped (see residual 1). +- **Duplicate targets typed twice.** They are now refused. +- **Partial delivery reported as skipped.** It is now `broadcast_partial`, + told not to re-run, with a nonzero exit. +- **The agent exiting between check and send.** The program is re-checked + between typing and Enter; Enter is withheld if it changed. +- **A locale-dependent control-character filter.** Validation moved to Node + with explicit code points, plus a UTF-8 validity check; tested under `C` and + UTF-8. + +The items below were accepted as residual. + +## 1. Plugin-reported panes cannot show `blocked` + +On Herdr 0.7.5+ Swarm reports the slot's agent state itself, so an approval +prompt reads as `working`. Broadcast can then type into the prompt. This is +documented in the README. + +## 2. The check-then-act window is narrowed, not closed + +Four CLI calls are not atomic. With the Enter re-check, a lost race leaves +unsubmitted text in the shell. That is inert until someone presses Enter. + +## 3. Herdr key syntax inside send-text is unverified + +The live probe covered a raw newline only. If `pane send-text` interprets +key names inside text (``, `{Enter}`), a printable message could +still inject keys. + +## 4. The terminal-id check assumes ids are never recycled + +Pane reuse is caught by comparing `terminal_id`. That is safe only if Herdr +never reissues one; this is unverified. + +## 5. Broadcast holds the repo lock across its Herdr calls + +Up to about 6 calls per slot at 5 s each. Settle and harvest verbs wait +behind it. + +## 6. Token usage and cost are not shown + +Herdr's pane `tokens` are display metadata, and nothing reports LLM usage +to Herdr. Deferred by decision on 2026-10-03 until an agent actually reports +usage. diff --git a/scripts/compare.mjs b/scripts/compare.mjs index 329b74e..d103d6d 100644 --- a/scripts/compare.mjs +++ b/scripts/compare.mjs @@ -43,13 +43,23 @@ function validationFor(stateDir, runId, slot, tip) { return { status: value.status, failed_checks: failed }; } -export function compareSlots({ repo, fork, runId, stateDir, rows }) { - const slots = rows.map(({ slot, label, branch, path: wt, status, finished }) => { +// Seconds from a slot's start to its finish, or to now while it runs; null +// when the start is unknown ("-" in the manifest). +function elapsedSeconds(startedAt, finishedAt, now) { + const start = Date.parse(startedAt ?? ""); + if (!Number.isFinite(start)) return null; + const end = Date.parse(finishedAt ?? ""); + return Math.max(0, Math.round(((Number.isFinite(end) ? end : now) - start) / 1000)); +} + +export function compareSlots({ repo, fork, runId, stateDir, rows, now = Date.now() }) { + const slots = rows.map(({ slot, label, branch, path: wt, status, finished, startedAt, finishedAt }) => { const tip = git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`])?.trim() || null; // files stays null when the diff could not be read: "no files" would be // a false claim of zero overlap with every other slot. const out = { slot, label, branch, status, finished: finished || null, tip, commits: null, - files: null, insertions: null, deletions: null, dirty: null, validation: { status: "none" } }; + files: null, insertions: null, deletions: null, dirty: null, validation: { status: "none" }, + elapsed_seconds: elapsedSeconds(startedAt, finishedAt, now) }; if (tip) { const count = git(repo, ["rev-list", "--count", `${fork}..${tip}`]); out.commits = count === null ? null : Number(count.trim()); @@ -111,8 +121,8 @@ function main() { const [repo, fork, runId, stateDir] = process.argv.slice(2); // Fields are \x1f-separated, like read_slot's: TAB is legal in a path. const rows = fs.readFileSync(0, "utf8").split("\n").filter(Boolean).map((line) => { - const [slot, label, branch, wt, status, finished] = line.split("\x1f"); - return { slot: Number(slot), label, branch, path: wt, status, finished }; + const [slot, label, branch, wt, status, finished, startedAt, finishedAt] = line.split("\x1f"); + return { slot: Number(slot), label, branch, path: wt, status, finished, startedAt, finishedAt }; }); const { slots, overlaps } = compareSlots({ repo, fork, runId, stateDir, rows }); for (const s of slots) { diff --git a/scripts/fanout-pane.sh b/scripts/fanout-pane.sh index d565cc3..453d78d 100755 --- a/scripts/fanout-pane.sh +++ b/scripts/fanout-pane.sh @@ -201,15 +201,20 @@ parse_start_json() { } running_patch_json() { - # argv: name pane terminal ws → the row-running patch. + # argv: name pane terminal ws command → the row-running patch. node -e ' - const [an, pi, ti, w] = process.argv.slice(1); + const [an, pi, ti, w, cmd] = process.argv.slice(1); process.stdout.write(JSON.stringify({ status: "running", agent_name: an || null, pane_id: pi || null, terminal_id: ti || null, workspace_id: w || null, + // The program the slot runs (basename of the preset argv[0]): broadcast + // types only into a pane whose foreground leader is this program. + agent_command: cmd ? require("path").basename(cmd) : null, + // Elapsed time per slot (status and compare views) counts from here. + started_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"), })); ' "$@" } @@ -762,7 +767,7 @@ for ((i = 1; i <= n; i++)); do IFS=$'\t' read -r a_name a_pane a_term a_ws <<<"$sinfo" # Row running with the ids the START returned (spike (k)): the agent's # pane, not the worktree root pane recorded above. - rpatch="$(running_patch_json "$a_name" "$a_pane" "$a_term" "$a_ws")" || fatal 1 "herdr-swarm: internal error building the running patch." + rpatch="$(running_patch_json "$a_name" "$a_pane" "$a_term" "$a_ws" "${argv_arr[0]}")" || fatal 1 "herdr-swarm: internal error building the running patch." manifest_update_slot "$i" "$rpatch" || fatal 1 "herdr-swarm: manifest write failed marking slot $i running — stopping." started=$((started + 1)) echo "herdr-swarm: slot $i running — $branch -> $wt_path" diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index c7a3e90..516f267 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -14,7 +14,7 @@ # resume [complete ] | archive | abort-merge | # publish | publish-pr | publish-candidate-pr | # candidate-status | pr-status | validate | -# settle | compare +# settle | compare | broadcast # # Output protocol: machine-readable "keyvalue…" lines on stdout, human # messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on @@ -924,22 +924,182 @@ do_settle() { printf 'notified\t%s\n' "$summary" } +# broadcast: type one message into every targeted slot's agent and submit it. +# The message comes from HERDR_SWARM_MESSAGE_FILE or HERDR_SWARM_MESSAGE and +# must be ONE line of printable text: a newline inside send-text arrives as a +# real line break (live-verified), so a TUI agent would submit half of it, and +# control characters are keystrokes (Ctrl-C, escape sequences), not text. +# Targets: HERDR_SWARM_TARGETS=1,3 or, by default, every running slot. +# +# Typing into the wrong program could RUN the message — a shell executes it, +# `less` runs `!…`, vim reads it as commands, an approval dialog takes it as +# option keys and Enter as "yes". So a slot receives it only when, checked +# right before typing: +# 1. its pane still holds the slot's own terminal (result.pane.terminal_id); +# 2. Herdr does not report the agent `blocked` (waiting on the user); +# 3. the foreground process GROUP LEADER is the slot's own agent program +# (agent_command, recorded at fan-out) — not the shell, and not anything +# the user or the agent started in the pane; +# 4. it has not finished by exiting (the shell owns that pane again). +# The program is checked again between typing and Enter; if it changed, Enter +# is withheld (text left unsent is inert). Records: broadcast_sentslot, +# broadcast_skippedslotwhy, broadcast_partialslotwhy +# (typed but not submitted). Exits 36 unless every target was sent. +do_broadcast() { + local msg rows s want_cmd finished got term status fg sent=0 failed=0 + # Validated in node, not with [[:cntrl:]]: that class depends on the + # caller's locale (under LC_ALL=C it passes C1 controls like U+009B CSI). + msg="$(HERDR_SWARM_MESSAGE="${HERDR_SWARM_MESSAGE:-}" node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const fail = (why) => { console.error("herdr-swarm: " + why); process.exit(1); }; + let msg; + if (file) { + let bytes; + try { bytes = fs.readFileSync(file); } catch { fail("HERDR_SWARM_MESSAGE_FILE \x27" + file + "\x27 is not a readable file."); } + try { msg = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail("broadcast message file is not valid UTF-8."); } + msg = msg.replace(/\r?\n$/, ""); + } else msg = process.env.HERDR_SWARM_MESSAGE; + if (!msg || !msg.trim()) fail("broadcast needs a message — set HERDR_SWARM_MESSAGE or HERDR_SWARM_MESSAGE_FILE."); + const banned = (c) => c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x200e || c === 0x200f || c === 0x2028 || c === 0x2029 || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069) || c === 0xfeff || c === 0xfffd; + if ([...msg].some((ch) => banned(ch.codePointAt(0)))) + fail("broadcast message must be one line of plain text (no newlines, tabs, control or direction-override characters) — an agent would receive a newline as \x27submit\x27."); + if (msg.startsWith("-")) fail("broadcast message must not start with \x27-\x27 — the Herdr CLI would parse it as a flag; rephrase the start."); + if ([...msg].length > 4000) fail("broadcast message is over 4000 characters — put longer instructions in a file and tell the agents to read it."); + process.stdout.write(msg); + ' "${HERDR_SWARM_MESSAGE_FILE:-}")" || return "$HS_EC_REFUSED" + rows="$(printf '%s' "$DOC" | node -e ' + const want = process.argv[1]; + let d = ""; + process.stdin.on("data", (c) => (d += c)).on("end", () => { + const slots = JSON.parse(d).slots || []; + const row = (n) => slots.find((r) => String(r.slot) === n); + const line = (n) => { + const r = row(n); + console.log([n, r?.agent_command || "-", r?.finished?.reason || "-"].join("\t")); + }; + if (!want) { for (const r of slots) if (r.status === "running") line(String(r.slot)); return; } + const seen = new Set(); + for (const t of want.split(",")) { + const n = t.trim(); + if (!/^[1-9][0-9]*$/.test(n)) { console.error("herdr-swarm: HERDR_SWARM_TARGETS entry \x27" + n + "\x27 is not a slot number"); process.exit(1); } + // A repeat would type the same instruction into one agent twice. + if (seen.has(n)) { console.error("herdr-swarm: HERDR_SWARM_TARGETS names slot " + n + " twice"); process.exit(1); } + seen.add(n); + } + for (const n of seen) line(n); + }); + ' "${HERDR_SWARM_TARGETS:-}")" || return "$HS_EC_REFUSED" + while IFS=$'\t' read -r s want_cmd finished; do + [ -n "$s" ] || continue + if ! read_slot "$s" 2>/dev/null; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "not a slot of this run, or its ownership check failed" + failed=$((failed + 1)) + continue + fi + if [ "$SLOT_STATUS" != running ]; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "slot is '$SLOT_STATUS'" + failed=$((failed + 1)) + continue + fi + if [ "$finished" = exited ]; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "its agent exited — the pane belongs to the shell again" + failed=$((failed + 1)) + continue + fi + if [ -z "$SLOT_PANE" ] || [ -z "$SLOT_TERMINAL" ] || [ "$want_cmd" = "-" ]; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "no recorded pane, terminal, or agent program (a run started before broadcast existed)" + failed=$((failed + 1)) + continue + fi + got="$(herdr_pane_get "$SLOT_PANE" 2>/dev/null)" || got="" + IFS=$'\t' read -r term status <<<"$(printf '%s' "$got" | node -e ' + let d = ""; process.stdin.on("data", (c) => (d += c)).on("end", () => { + let p; try { p = JSON.parse(d).result.pane; } catch {} + process.stdout.write([p?.terminal_id ?? "", p?.agent_status ?? ""].join("\t")); + });' 2>/dev/null)" + if [ "$term" != "$SLOT_TERMINAL" ]; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "its pane no longer holds this slot's terminal" + failed=$((failed + 1)) + continue + fi + if [ "$status" = blocked ]; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "the agent is blocked waiting on you — keystrokes would answer its prompt" + failed=$((failed + 1)) + continue + fi + fg="$(pane_foreground_program "$SLOT_PANE")" + if ! _foreground_is "$fg" "$want_cmd"; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "the foreground is not its agent '$want_cmd' ($(_foreground_label "$fg"))" + failed=$((failed + 1)) + continue + fi + if ! herdr_pane_send_text "$SLOT_PANE" "$msg" >/dev/null 2>&1; then + printf 'broadcast_skipped\t%s\t%s\n' "$s" "typing was refused (send-text failed)" + failed=$((failed + 1)) + continue + fi + # Narrow the check-then-act window: if the agent exited while the text + # was typed, the shell would run it on Enter. Unsubmitted text is inert. + fg="$(pane_foreground_program "$SLOT_PANE")" + if ! _foreground_is "$fg" "$want_cmd"; then + printf 'broadcast_partial\t%s\t%s\n' "$s" "typed, NOT submitted — the foreground changed to $(_foreground_label "$fg"); clear that pane's input line" + failed=$((failed + 1)) + continue + fi + if ! herdr_pane_send_keys "$SLOT_PANE" Enter >/dev/null 2>&1; then + printf 'broadcast_partial\t%s\t%s\n' "$s" "typed, NOT submitted — the Enter key was refused; press Enter in that pane or clear it (do not just re-run)" + failed=$((failed + 1)) + continue + fi + printf 'broadcast_sent\t%s\n' "$s" + sent=$((sent + 1)) + done <<<"$rows" + if [ "$sent" -eq 0 ]; then + echo "herdr-swarm: broadcast reached no slot (reasons above)." >&2 + return "$HS_EC_REFUSED" + fi + if [ "$failed" -gt 0 ]; then + echo "herdr-swarm: broadcast reached $sent slot(s); $failed did not get it (reasons above)." >&2 + return "$HS_EC_REFUSED" + fi +} + +# _foreground_is +_foreground_is() { + local kind a b + IFS=$'\t' read -r kind a b <<<"$1" + [ "$kind" = program ] && { [ "$a" = "$2" ] || [ "$b" = "$2" ]; } +} + +_foreground_label() { + local kind a b + IFS=$'\t' read -r kind a b <<<"$1" + case "$kind" in + program) printf '%s' "${b:+$a $b}${b:-$a}" ;; + *) printf '%s' "$kind" ;; + esac +} + # compare: read-only side-by-side facts for every non-archived slot, ranked # (see scripts/compare.mjs for the record format and the ranking rule). Rows # failing the ownership check are left out with a warning, never compared: # their branch/path are not provably this run's. do_compare() { - local slots s finished rows="" + local slots s finished started_at finished_at rows="" slots="$(printf '%s' "$DOC" | node -e ' let d = ""; process.stdin.on("data", (c) => (d += c)).on("end", () => { - for (const r of JSON.parse(d).slots || []) { + const doc = JSON.parse(d); + for (const r of doc.slots || []) { if (r.status === "archived") continue; - console.log([r.slot, r.finished?.reason ?? "-"].join("\t")); + // Start/finish times feed the elapsed column; timestamps only. + const iso = (v) => (typeof v === "string" && /^[0-9T:.Z-]+$/.test(v) ? v : "-"); + console.log([r.slot, r.finished?.reason ?? "-", iso(r.started_at ?? doc.created_at), iso(r.finished?.at)].join("\t")); } }); ')" || return 1 - while IFS=$'\t' read -r s finished; do + while IFS=$'\t' read -r s finished started_at finished_at; do [ -n "$s" ] || continue if ! read_slot "$s" 2>/dev/null; then echo "herdr-swarm: slot $s left out of compare — its ownership check failed." >&2 @@ -954,7 +1114,7 @@ do_compare() { continue ;; esac - rows+="$s$US$SLOT_LABEL$US$SLOT_BRANCH$US$SLOT_PATH$US$SLOT_STATUS$US$finished"$'\n' + rows+="$s$US$SLOT_LABEL$US$SLOT_BRANCH$US$SLOT_PATH$US$SLOT_STATUS$US$finished$US$started_at$US$finished_at"$'\n' done <<<"$slots" printf '%s' "$rows" | node "$PLUGIN_ROOT/scripts/compare.mjs" "$REPO_ROOT" "$FORK_SHA" "$RUN_ID" "$(state_dir)" } @@ -1420,6 +1580,9 @@ settle) compare) do_compare ;; +broadcast) + do_broadcast + ;; *) echo "herdr-swarm: unknown harvest verb '$VERB'" >&2 exit 1 diff --git a/scripts/lib.sh b/scripts/lib.sh index f3bc12c..f342e20 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -942,6 +942,24 @@ herdr_pane_process_info() { with_timeout 5 "$HERDR" pane process-info --pane "$@" } +# `pane get ` → result.pane {pane_id, terminal_id, workspace_id, …} +# (live-captured on 0.8.2). Read-only. +herdr_pane_get() { + with_timeout 5 "$HERDR" pane get "$@" +} + +# `pane send-text ` types the text into the pane's foreground +# program; `pane send-keys Enter` submits it. Both print nothing on +# success (live-verified on 0.8.2). A newline inside send-text arrives as a +# real line break — callers must keep messages single-line. +herdr_pane_send_text() { + with_timeout 5 "$HERDR" pane send-text "$@" +} + +herdr_pane_send_keys() { + with_timeout 5 "$HERDR" pane send-keys "$@" +} + # `notification show [--body T] [--sound none|done|request]` # (live-captured on 0.8.2): result = {type:"notification_show", shown, reason}. herdr_notification_show() { @@ -971,6 +989,36 @@ pane_foreground() { ' 2>/dev/null || printf 'unknown\n' } +# pane_foreground_program <pane_id>: `shell` / `unknown` as pane_foreground, +# otherwise `program<TAB>name` where name is the basename of the foreground +# process GROUP LEADER's argv[0] (pid == foreground_process_group_id). The +# leader, not just any member: a live 0.8.2 capture showed an agent's own +# helper processes (MCP servers, caffeinate) sharing its group, while a +# shell, pager or editor the user starts in the pane leads a group of its own. +# Interpreter-launched CLIs (`node …/codex`) also report argv[1]'s basename, +# as `program<TAB>node<TAB>codex`. +pane_foreground_program() { + local out + out="$(herdr_pane_process_info "${1-}" 2>/dev/null)" || { + printf 'unknown\n' + return 0 + } + printf '%s' "$out" | node -e ' + const path = require("path"); + let d = ""; + process.stdin.on("data", (c) => (d += c)).on("end", () => { + let p; + try { p = JSON.parse(d).result.process_info; } catch {} + if (!p || !Number.isSafeInteger(p.shell_pid) || !Number.isSafeInteger(p.foreground_process_group_id)) return console.log("unknown"); + if (p.foreground_process_group_id === p.shell_pid) return console.log("shell"); + const leader = (p.foreground_processes || []).find((x) => x?.pid === p.foreground_process_group_id); + const argv = Array.isArray(leader?.argv) ? leader.argv : []; + const names = argv.slice(0, 2).map((a) => path.basename(String(a))).filter((n) => /^[A-Za-z0-9._+-]{1,64}$/.test(n)); + console.log(names.length ? ["program", ...names].join("\t") : "unknown"); + }); + ' 2>/dev/null || printf 'unknown\n' +} + # report_slot_agent_state <pane_id> <agent label> <idle|working|blocked|unknown> # Correct a plugin-reported slot's agent state. NO-OP below 0.7.5: there herdr # detects the slot's agent natively, and a plugin report would fight that diff --git a/tests/broadcast.test.mjs b/tests/broadcast.test.mjs new file mode 100644 index 0000000..4079d43 --- /dev/null +++ b/tests/broadcast.test.mjs @@ -0,0 +1,181 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { createHarness, makeFannedOutRun, mkdtemp } from "./harness.mjs"; +import { slotElapsedSeconds, formatDuration, reconcileSlots } from "../bin/renderer-shared.mjs"; + +const h = createHarness(); +h.writeHerdrStub(); + +// A run whose slots run `claude` (agent_command, as fan-out records it), with +// every pane's foreground group led by `claude` unless a test says otherwise. +function fixture(slots = 3, env = {}, edit = () => {}) { + const run = makeFannedOutRun(h, { prefix: "bc", slots }); + const file = path.join(run.sdir, "run-w9.json"); + const doc = JSON.parse(fs.readFileSync(file, "utf8")); + for (const s of doc.slots) s.agent_command = "claude"; + edit(doc); + fs.writeFileSync(file, JSON.stringify(doc, null, 2)); + Object.assign(run.env, { STUB_PANE_FG: "busy", STUB_FG_ARGV: '["claude","--resume"]', STUB_HERDR_VERSION: "0.8.2" }, env); + return { + ...run, + send: (extra = {}) => { + fs.writeFileSync(h.logFile, ""); + return h.runScript("harvest-step.sh", ["broadcast"], { ...run.env, ...extra }); + }, + }; +} + +const records = (r, key) => r.stdout.split("\n").filter((l) => l.startsWith(`${key}\t`)).map((l) => l.split("\t").slice(1)); +const skipped = (r) => Object.fromEntries(records(r, "broadcast_skipped").map(([s, why]) => [s, why])); +const typed = () => h.log().split("\n").filter((l) => /pane send-(text|keys)/.test(l)); + +test("one message reaches every running slot: typed, then submitted", () => { + const run = fixture(); + const r = run.send({ HERDR_SWARM_MESSAGE: "Also add tests, then commit." }); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(records(r, "broadcast_sent"), [["1"], ["2"], ["3"]]); + assert.deepEqual(typed(), [ + "herdr pane send-text w11:p1 Also add tests, then commit.", + "herdr pane send-keys w11:p1 Enter", + "herdr pane send-text w12:p1 Also add tests, then commit.", + "herdr pane send-keys w12:p1 Enter", + "herdr pane send-text w13:p1 Also add tests, then commit.", + "herdr pane send-keys w13:p1 Enter", + ]); +}); + +test("types only into the slot's own agent: never a shell, a nested program, a reused pane, or a blocked prompt", () => { + const run = fixture(6, {}, (doc) => { + doc.slots[4].finished = { at: "2026-10-03T00:00:00Z", reason: "exited" }; + doc.slots[5].status = "skipped"; + }); + const r = run.send({ + HERDR_SWARM_MESSAGE: "stop and commit", + HERDR_SWARM_TARGETS: "1,2,3,4,5,6", + STUB_SHELL_PANES: "w12:p1", + STUB_REUSED_PANES: "w13:p1", + STUB_FOREIGN_PANES: "w14:p1", + STUB_BLOCKED_PANES: "w11:p1", + }); + assert.equal(r.status, 36, "not every target got it"); + assert.deepEqual(records(r, "broadcast_sent"), [], r.stdout); + const why = skipped(r); + assert.match(why["1"], /blocked waiting on you — keystrokes would answer its prompt/); + assert.match(why["2"], /the foreground is not its agent 'claude' \(shell\)/); + assert.match(why["3"], /no longer holds this slot's terminal/); + assert.match(why["4"], /the foreground is not its agent 'claude' \(bash\)/, "the leader decides, not a member"); + assert.match(why["5"], /its agent exited — the pane belongs to the shell again/); + assert.match(why["6"], /slot is 'skipped'/); + assert.deepEqual(typed(), [], "nothing typed anywhere"); +}); + +test("an interpreter-launched agent (node …/codex) is recognized by argv[1]", () => { + const run = fixture(1, { STUB_FG_ARGV: '["node","/opt/lib/codex"]' }, (doc) => { doc.slots[0].agent_command = "codex"; }); + assert.deepEqual(records(run.send({ HERDR_SWARM_MESSAGE: "hi" }), "broadcast_sent"), [["1"]]); +}); + +test("a row from before broadcast existed (no agent_command) is skipped, not guessed", () => { + const run = fixture(1, {}, (doc) => { delete doc.slots[0].agent_command; }); + const r = run.send({ HERDR_SWARM_MESSAGE: "hi" }); + assert.equal(r.status, 36); + assert.match(skipped(r)["1"], /no recorded pane, terminal, or agent program/); + assert.deepEqual(typed(), []); +}); + +test("Enter is withheld when the foreground changed after typing, and a failed Enter is 'partial'", () => { + const flip = fixture(1, { STUB_FG_FLIP_FILE: path.join(mkdtemp("hs-flip-"), "flipped") }); + const r = flip.send({ HERDR_SWARM_MESSAGE: "make sure lint passes" }); + assert.equal(r.status, 36); + assert.match(records(r, "broadcast_partial")[0][1], /typed, NOT submitted — the foreground changed to shell/); + assert.deepEqual(typed(), ["herdr pane send-text w11:p1 make sure lint passes"], "no Enter into the shell"); + const fail = fixture(1, { STUB_SENDKEYS_FAIL: "1" }); + const p = fail.send({ HERDR_SWARM_MESSAGE: "x" }); + assert.equal(p.status, 36); + assert.match(records(p, "broadcast_partial")[0][1], /the Enter key was refused; press Enter in that pane or clear it \(do not just re-run\)/); + assert.deepEqual(records(p, "broadcast_skipped"), [], "partial is not reported as skipped"); +}); + +test("unknown foreground (older herdr) is never the agent: nothing is typed", () => { + const run = fixture(2, { STUB_PANE_FG: "" }); + const r = run.send({ HERDR_SWARM_MESSAGE: "hi" }); + assert.equal(r.status, 36); + assert.match(r.stderr, /reached no slot/); + assert.deepEqual(typed(), []); +}); + +test("targets pick slots; duplicates and junk refuse before anything is typed", () => { + const run = fixture(); + const r = run.send({ HERDR_SWARM_MESSAGE: "only you two", HERDR_SWARM_TARGETS: "1, 3" }); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(records(r, "broadcast_sent"), [["1"], ["3"]]); + for (const [targets, why] of [["1,two", /'two' is not a slot number/], ["1,3,1", /names slot 1 twice/]]) { + const bad = run.send({ HERDR_SWARM_MESSAGE: "x", HERDR_SWARM_TARGETS: targets }); + assert.equal(bad.status, 36); + assert.match(bad.stderr, why); + assert.deepEqual(typed(), []); + } + const missing = run.send({ HERDR_SWARM_MESSAGE: "x", HERDR_SWARM_TARGETS: "9" }); + assert.equal(missing.status, 36); + assert.match(skipped(missing)["9"], /not a slot of this run/); +}); + +test("messages must be one line of plain text, in any locale", () => { + const run = fixture(1); + const cases = [ + ["", /needs a message/], + [" ", /needs a message/], + ["line one\nline two", /one line of plain text/], + ["tab\there", /one line of plain text/], + ["ctrl-c \u0003 now", /one line of plain text/], + ["esc \u001b[31m red", /one line of plain text/], + ["c1 csi \u009b31m", /one line of plain text/], + ["next line \u0085 here", /one line of plain text/], + ["line sep 
 here", /one line of plain text/], + ["bidi ‮ reversed", /one line of plain text/], + ["--force everything", /must not start with '-'/], + ["x".repeat(4001), /over 4000 characters/], + ]; + for (const locale of ["C", "en_US.UTF-8"]) { + for (const [msg, why] of cases) { + const r = run.send({ HERDR_SWARM_MESSAGE: msg, LC_ALL: locale }); + assert.equal(r.status, 36, `${locale}: ${JSON.stringify(msg).slice(0, 40)}`); + assert.match(r.stderr, why); + assert.deepEqual(typed(), [], "nothing typed for a refused message"); + } + } + assert.deepEqual(records(run.send({ HERDR_SWARM_MESSAGE: "naïve café — ünïcode is fine" }), "broadcast_sent"), [["1"]]); +}); + +test("a message file works; a trailing newline is fine, an inner one or invalid UTF-8 is not", () => { + const run = fixture(1); + const dir = mkdtemp("hs-msg-"); + const ok = path.join(dir, "ok.txt"); + fs.writeFileSync(ok, "Rebase on main before you finish.\n"); + assert.deepEqual(records(run.send({ HERDR_SWARM_MESSAGE_FILE: ok }), "broadcast_sent"), [["1"]]); + assert.deepEqual(typed()[0], "herdr pane send-text w11:p1 Rebase on main before you finish."); + const multi = path.join(dir, "multi.txt"); + fs.writeFileSync(multi, "one\ntwo\n"); + assert.equal(run.send({ HERDR_SWARM_MESSAGE_FILE: multi }).status, 36); + const bad = path.join(dir, "bad.txt"); + fs.writeFileSync(bad, Buffer.from([0x68, 0x69, 0x9b, 0x33])); + const r = run.send({ HERDR_SWARM_MESSAGE_FILE: bad }); + assert.equal(r.status, 36); + assert.match(r.stderr, /not valid UTF-8/); + assert.equal(run.send({ HERDR_SWARM_MESSAGE_FILE: path.join(dir, "absent") }).status, 36); +}); + +test("elapsed time: from start to finish, or to now; unknown start is unknown", () => { + const now = Date.parse("2026-10-03T12:00:00Z"); + assert.equal(slotElapsedSeconds({ started_at: "2026-10-03T11:30:00Z" }, null, now), 1800); + assert.equal(slotElapsedSeconds({ started_at: "2026-10-03T11:30:00Z", finished: { at: "2026-10-03T11:42:30Z" } }, null, now), 750); + assert.equal(slotElapsedSeconds({}, "2026-10-03T10:00:00Z", now), 7200, "older rows fall back to the run's start"); + assert.equal(slotElapsedSeconds({}, null, now), null); + assert.deepEqual([null, 0, 59, 60, 3599, 3600, 3900].map(formatDuration), ["-", "0s", "59s", "1m", "59m", "1h00m", "1h05m"]); + const rows = reconcileSlots([ + { slot: 1, status: "running", started_at: "2026-10-03T11:58:00Z" }, + { slot: 2, status: "merged", started_at: "2026-10-03T11:00:00Z" }, + ], [], {}, null, now); + assert.deepEqual(rows.map((r) => r.elapsed), [120, null], "settled history shows no live clock"); +}); diff --git a/tests/fanout.test.mjs b/tests/fanout.test.mjs index 8d051d5..d5aafa2 100644 --- a/tests/fanout.test.mjs +++ b/tests/fanout.test.mjs @@ -1062,6 +1062,9 @@ test("env-driven: SLOTS+PRESETS+TASK fan out with no stdin at all", () => { assert.equal(m.slots.length, 2); assert.equal(m.slots[0].branch, `swarm/${m.run_id}/s1-claude`); assert.equal(m.slots[1].branch, `swarm/${m.run_id}/s2-codex`); + // What broadcast checks the pane's foreground against, and when time starts. + assert.deepEqual(m.slots.map((s) => s.agent_command), ["claude", "codex"]); + for (const s of m.slots) assert.match(s.started_at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); for (const s of m.slots) { assert.equal(s.status, "running"); assert.ok( diff --git a/tests/harness.mjs b/tests/harness.mjs index e4ad083..af0a95b 100644 --- a/tests/harness.mjs +++ b/tests/harness.mjs @@ -359,12 +359,44 @@ if [ "$1" = "pane" ] && [ "$2" = "report-agent" ]; then exit 0 fi if [ "$1" = "pane" ] && [ "$2" = "read" ]; then exit "\${STUB_PANE_ALIVE:-1}"; fi +if [ "$1" = "pane" ] && [ "$2" = "get" ]; then + # Live-captured pane_info shape on 0.8.2. Fixture panes are w1N:p1 for slot + # N with terminal term_sN; STUB_REUSED_PANES lists panes whose terminal is + # now a different one (the slot's pane was reused). + n="\${3#w1}"; n="\${n%%:*}" + term="term_s\${n}" + case " \${STUB_REUSED_PANES:-} " in *" $3 "*) term="term_other" ;; esac + status=unknown + case " \${STUB_BLOCKED_PANES:-} " in *" $3 "*) status=blocked ;; esac + echo '{"id":"cli:pane:get","result":{"pane":{"agent_status":"'"$status"'","focused":false,"pane_id":"'"$3"'","revision":3,"tab_id":"w9:t1","terminal_id":"'"$term"'","workspace_id":"w9"},"type":"pane_info"}}' + exit 0 +fi +if [ "$1" = "pane" ] && { [ "$2" = "send-text" ] || [ "$2" = "send-keys" ]; }; then + # Live-verified: both print nothing on success. + if [ "$2" = "send-keys" ] && [ -n "\${STUB_SENDKEYS_FAIL:-}" ]; then exit 1; fi + exit 0 +fi if [ "$1" = "pane" ] && [ "$2" = "process-info" ]; then # Live-captured on 0.8.2 (protocol 20), paths sanitized. STUB_PANE_FG picks # the pane's foreground: "busy" (argv running), "shell" (argv exited — the # foreground group IS the shell), unset = herdr refuses (older herdr). - case "\${STUB_PANE_FG:-}" in - busy) echo '{"id":"cli:pane:process_info","result":{"process_info":{"foreground_process_group_id":53782,"foreground_processes":[{"argv":["sleep","40"],"argv0":"sleep","cmdline":"sleep 40","cwd":"/tmp/herdr-worktrees/repo/swarm-r1-s1","name":"sleep","pid":53782}],"pane_id":"w9:p7","shell_pid":53593},"type":"pane_process_info"}}' ;; + # STUB_SHELL_PANES lists panes that read "shell" whatever STUB_PANE_FG says; + # STUB_FG_ARGV (JSON array) is the busy group leader's argv (default + # ["sleep","40"]); STUB_FOREIGN_PANES lead with a user-started \`bash\` instead, + # beside the agent as a non-leader member (as a nested shell would be); + # STUB_FG_FLIP_FILE flips a pane to "shell" on the call after it was busy. + fg="\${STUB_PANE_FG:-}" + case " \${STUB_SHELL_PANES:-} " in *" $4 "*) fg=shell ;; esac + if [ -n "\${STUB_FG_FLIP_FILE:-}" ] && [ "$fg" = busy ]; then + if [ -e "$STUB_FG_FLIP_FILE" ]; then fg=shell; else : > "$STUB_FG_FLIP_FILE"; fi + fi + argv="\${STUB_FG_ARGV:-[\\"sleep\\",\\"40\\"]}" + case " \${STUB_FOREIGN_PANES:-} " in *" $4 "*) + echo '{"id":"cli:pane:process_info","result":{"process_info":{"foreground_process_group_id":61000,"foreground_processes":[{"argv":'"$argv"',"pid":53782},{"argv":["bash"],"argv0":"bash","name":"bash","pid":61000}],"pane_id":"'"$4"'","shell_pid":53593},"type":"pane_process_info"}}' + exit 0 ;; + esac + case "$fg" in + busy) echo '{"id":"cli:pane:process_info","result":{"process_info":{"foreground_process_group_id":53782,"foreground_processes":[{"argv":'"$argv"',"argv0":"sleep","cmdline":"sleep 40","cwd":"/tmp/herdr-worktrees/repo/swarm-r1-s1","name":"sleep","pid":53782}],"pane_id":"w9:p7","shell_pid":53593},"type":"pane_process_info"}}' ;; shell) echo '{"id":"cli:pane:process_info","result":{"process_info":{"foreground_process_group_id":53593,"foreground_processes":[{"argv":["-zsh"],"argv0":"zsh","cmdline":"-zsh","cwd":"/tmp/herdr-worktrees/repo/swarm-r1-s1","name":"zsh","pid":53593}],"pane_id":"w9:p7","shell_pid":53593},"type":"pane_process_info"}}' ;; *) exit 1 ;; esac