diff --git a/.claude/knowledge/_index.md b/.claude/knowledge/_index.md index da9f191..a13e7bd 100644 --- a/.claude/knowledge/_index.md +++ b/.claude/knowledge/_index.md @@ -13,6 +13,7 @@ - `features/backfill-and-origin-metadata.md` — `/backfill-knowledge` significance bar + origin-reconstruction cascade - `features/statusline-integration.md` — Status-line segments: plugins can't own `statusLine.command`; marker-block injection; the `[ks …]` + `[ws …]` two-segment coexistence and ws's never-block-on-network PR cache - `features/herdr-kickoff-automation.md` — herdr `herdr-launch.sh`: `launch` (`/kickoff` + `/adopt`) + `resume` (`/continue ` reopens an `/exit`-closed tab) +- `features/lane-registry.md` — `lanes.sh` + `herdr-agent.sh` (Wave 1): the one herdr-agent wrapper (degrade-not-block, bounded wait) + centralized `$HERDR_MATCH_PRELUDE` cwd↔worktree match (consumed by herdr-tab-glyph, regression-guarded via live snapshot); lanes.sh joins states+liveness keyed by worktree_path with a worktree-tab-state degrade tri-state; env test-seams for hermetic join tests - `features/herdr-close-automation.md` — `/close` in herdr: cwd-tab teardown, plugin SessionEnd hook, the one TUI-exit primitive, detached self-exit onto idle - `features/herdr-tab-glyphs.md` — Task-state glyphs (`○ ● ◇ ◆ ✓`) + main-root `◉` on herdr tab labels: `states` mode in the self-contained renderer, sync-vs-`--cached` PR refresh per caller, exact-cwd rename rules, soft pr-flow shim - `features/kickoff-agent-selection.md` — `/kickoff` worker choice: single committed per-repo default (no global/fallback/ranking) else picker; `agent-registry.sh` as SoT; bounded model-aware grok probe (inconclusive→trust-auth); non-claude "document, don't fake" degradation; announce-not-prompt for external defaults diff --git a/.claude/knowledge/features/herdr-tab-glyphs.md b/.claude/knowledge/features/herdr-tab-glyphs.md index 7e3301f..c2d4e64 100644 --- a/.claude/knowledge/features/herdr-tab-glyphs.md +++ b/.claude/knowledge/features/herdr-tab-glyphs.md @@ -78,7 +78,10 @@ was shipped and caught in review. - **Matching:** exact realpath equality against `
/.claude/worktrees/` (→ state glyph) or the main repo root itself (→ `◉`) — same philosophy as `herdr-teardown.sh`: an agent cd'd into a *subdir* of either is never renamed, - and anything outside the repo never is. `◉` needs no new trigger — the same + and anything outside the repo never is. The match itself no longer lives inline + here: it is `classify_cwd` in `herdr-agent.sh`'s `$HERDR_MATCH_PRELUDE`, the + single source of truth this script and `lanes.sh` both consume — change match + semantics there, not in either consumer (see [lane-registry](lane-registry.md)). `◉` needs no new trigger — the same `refresh` sweep stamps both. The match needs **both** herdr lists joined on `tab_id`: only agents carry `cwd` (the match key), only tabs carry `label` (what we stamp). One tab is stamped once (first matching agent wins — a diff --git a/.claude/knowledge/features/lane-registry.md b/.claude/knowledge/features/lane-registry.md new file mode 100644 index 0000000..1585c58 --- /dev/null +++ b/.claude/knowledge/features/lane-registry.md @@ -0,0 +1,71 @@ +--- +title: "Lane registry: lanes.sh + herdr-agent.sh" +createdAt: 2026-07-24 +updatedAt: 2026-07-24 +createdFrom: "branch: task/add-lane-registry" +updatedFrom: "branch: task/add-lane-registry" +pluginVersion: 1.9.0 +prime: false +--- + +# Lane registry: `lanes.sh` + `herdr-agent.sh` + +Wave-1 foundation for the coordinated Manager/Worker model (see +[manager-worker-orchestration](../architecture/manager-worker-orchestration.md)). +Two work-system scripts; all later orchestration tasks consume them. + +## `herdr-agent.sh` — the one herdr-agent wrapper + the shared cwd-match + +Guarded wrappers over the `herdr agent list|get|read|wait` primitives (see the +script header for the exact exit-code contract and the bounded-wait default). +Two design points that matter downstream: + +- **Degrade, never block.** Missing `herdr`/`python3`, an unreachable server, or + malformed JSON is a non-zero exit with empty stdout — distinct codes so a + caller can tell *why*. `read` is best-effort (free-form pane text, no schema); + `wait` injects a default `--timeout` when the caller omits one, so a wrapper + can never hang (the "no busy loop" guarantee). +- **It is the single home of the realpath cwd↔worktree match.** The match lives + as `$HERDR_MATCH_PRELUDE` — a python source string (`match_roots` + + `classify_cwd`) that consumers *prepend* to their own snippet. Sourcing the + script is **side-effect free** (a `BASH_SOURCE[0] == $0` guard suppresses the + CLI dispatch), so a consumer can pull in just the prelude. + +`herdr-tab-glyph.sh` was refactored to consume that prelude instead of its own +inline copy (it drove the [tab glyphs](herdr-tab-glyphs.md)). It has **no unit +test**, so the refactor was regression-guarded by running its classification +over a *live herdr snapshot* before and after and confirming byte-identical +output — the technique to reach for when refactoring an untested renderer. + +`herdr-teardown.sh` was deliberately **left alone**: its `realpath(cwd) == +target` lookup is a 1:1 match against one given path returning a tab id — a +*different* operation from the 1:N "classify this cwd against the repo's whole +worktrees dir" that `classify_cwd` does. Forcing it through the shared helper +would be scope creep with regression risk on a tested path. + +## `lanes.sh` — the derived-live lane view + +Joins `ws-statusline.sh states --cached` (backlog state+glyph) with `herdr agent +list` (liveness), one row per active worktree. `--cached` = pure survey, never +blocks on `gh`. TSV by default, `--json` for objects (columns listed in the +script header). + +- **Keyed by worktree path**, never pane/tab ids — those churn and are liveness + data, not identity. The lane *set* comes from `git worktree list` (also the + source of the `branch` column), filtered to direct children of + `.claude/worktrees/`; state joins by task name, liveness by realpath'd cwd + (first agent in a worktree wins). +- **Liveness degrade tri-state**, mirroring `herdr-teardown`'s + `worktree-tab-state`: outside a herdr session (`HERDR_ENV != 1`) → blank; + inside herdr but the list is unreachable/empty/malformed → fail-closed + `unverified` (never a guessed liveness); populated list but this worktree + unmatched → confidently blank (there really is no live worker). + +## Testability seam + +Both the join and its degrade paths are unit-tested without a real repo/herdr +via three env **test seams** — `LANES_WORKTREES_FILE` / `LANES_STATES_FILE` / +`LANES_AGENTS_FILE` inject the three raw inputs, so `test_lanes.py` exercises +join / degraded / unverified / first-wins / exclusion hermetically. +`test_herdr_agent.py` covers the primitives + degrade + bounded wait with a fake +`herdr` on PATH. Both run under `check-structure.py`'s plugin-test check. diff --git a/plugins/work-system/scripts/herdr-agent.sh b/plugins/work-system/scripts/herdr-agent.sh new file mode 100755 index 0000000..12e7863 --- /dev/null +++ b/plugins/work-system/scripts/herdr-agent.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# herdr-agent.sh — the ONE wrapper over the herdr *agent* primitives, plus the +# ONE realpath cwd↔worktree classification the lane registry (lanes.sh) and the +# tab-glyph refresh (herdr-tab-glyph.sh) share. +# +# Two ways to use it: +# +# 1. As a CLI — thin, guarded wrappers over `herdr agent …`: +# herdr-agent.sh list (validated agent-list JSON) +# herdr-agent.sh get (validated single-agent JSON) +# herdr-agent.sh read [flags…] (best-effort text, never a schema) +# herdr-agent.sh wait --status S [--timeout MS] (bounded) +# Every wrapper DEGRADES rather than blocks: a missing herdr/python3, an +# unreachable server, or malformed JSON is a non-zero exit with empty +# stdout — never a hang, never a partial/garbage line. Exit codes below. +# +# 2. As a sourced library — `. herdr-agent.sh` pulls in, with NO side effects: +# $HERDR_MATCH_PRELUDE a python3 source string defining match_roots() +# and classify_cwd() — prepend it to a consumer +# python snippet so the cwd match logic is reused, +# never copied (the "realpath cwd match prelude"). +# ha_have / ha_list / ha_get / ha_read / ha_wait the shell wrappers. +# Sourcing must stay side-effect free (the CLI dispatch at the foot is +# guarded by "am I the executed script?") so a consumer can take the +# prelude without running a subcommand. +# +# Exit codes (CLI + ha_* functions): +# 0 success +# 2 usage — a required argument was missing +# 3 herdr and/or python3 not on PATH (degrade — tools absent) +# 4 herdr call failed / empty output (degrade — server unreachable) +# 5 output was not the expected JSON shape (degrade — malformed) +# read/wait forward herdr's own non-zero (e.g. wait timeout) unchanged. +# +# `set -u` is enabled ONLY on the executed-CLI path (foot of the file), never at +# source time: sourcing for $HERDR_MATCH_PRELUDE / the ha_* helpers must not +# mutate the caller's shell options (the side-effect-free contract). The wrappers +# guard their own expansions with ${x:-}, so they are correct either way. + +# ---- shared realpath cwd↔worktree match (the "prelude") --------------------- +# A python3 source string, apostrophe-free so it stays single-quotable. Both +# consumers prepend it to their own snippet: +# python3 -c "$HERDR_MATCH_PRELUDE"$'\n'"$their_snippet" … +# so the exact-match philosophy (mirrors herdr-teardown.sh: an agent merely cd'd +# into a SUBDIR of a worktree/root is neither) lives in exactly one place. +HERDR_MATCH_PRELUDE='import os +def match_roots(main): + # Realpath the repo root and its worktrees dir once. Empty main -> (None, None) + # so a caller can bail before touching agents. realpath the whole worktrees + # path (not just root): a symlinked .claude/worktrees would otherwise never + # match, since the cwd side below is resolved. + if not main or not main.strip(): + return None, None + root = os.path.realpath(main) + wtdir = os.path.realpath(os.path.join(root, ".claude", "worktrees")) + return root, wtdir + +def classify_cwd(cwd, root, wtdir): + # Exact realpath match. Returns a 3-tuple (kind, key, resolved): + # ("main", , ) cwd IS the main repo root + # ("task", , ) cwd IS a direct child of worktrees + # (None, None, None) neither (incl. a mere subdir) + # `resolved` is the realpath of cwd — returned so a caller keying by full path + # (lanes.sh) reuses this resolution instead of computing realpath a second time. + # cwd is realpath-resolved here; root/wtdir come pre-resolved from match_roots. + cwd = (cwd or "").rstrip("/") + if not cwd or root is None: + return None, None, None + cwd = os.path.realpath(cwd) + if cwd == root: + return "main", os.path.basename(root), cwd + if os.path.dirname(cwd) == wtdir: + return "task", os.path.basename(cwd), cwd + return None, None, None' + +# Default bound for `wait` when the caller passes none — a wrapper must never +# wait unboundedly (that would be the busy loop this script exists to avoid). +HA_WAIT_DEFAULT_TIMEOUT_MS="${HA_WAIT_DEFAULT_TIMEOUT_MS:-10000}" + +# Wall-clock bound for the one-shot list/get/read calls, so a herdr server that +# accepts a request but never answers can't hang a survey (the header's "never a +# hang" promise). `wait` is excluded — the caller's --timeout already bounds it. +HA_CALL_TIMEOUT_SECS="${HA_CALL_TIMEOUT_SECS:-10}" + +# ---- primitive wrappers ----------------------------------------------------- + +# Both tools present? (herdr for the call, python3 for the JSON validate.) +ha_have() { + command -v herdr >/dev/null 2>&1 || return 1 + command -v python3 >/dev/null 2>&1 || return 1 +} + +# A usable : non-empty and not a leading-dash value (which `herdr agent` +# would parse as an option flag, so an untrusted id can't smuggle a flag). Fails +# with 2 (usage). Shared by get/read/wait so the guard lives in exactly one place. +_ha_check_target() { + [ -n "${1:-}" ] || return 2 + case "$1" in -*) return 2 ;; esac +} + +# Run "$@" under a $1-second wall bound. `timeout` isn't on stock macOS; fall back +# to gtimeout, then perl's alarm (ships with macOS; the timer survives exec). +# A host with none runs unbounded — accepted residual, same tradeoff as +# ws-statusline.sh's run_bounded. A timeout kills the child → non-zero exit, +# which the callers already map to their degrade code. +_ha_bounded() { + local secs="$1"; shift + if command -v timeout >/dev/null 2>&1; then timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" "$@" + elif command -v perl >/dev/null 2>&1; then perl -e 'alarm shift; exec @ARGV' "$secs" "$@" + else "$@" + fi +} + +# Validate that stdin is JSON whose result. is a list; exit 0/1. Used to +# reject a malformed/blank blob before a consumer trusts it. +_ha_validate_list() { + python3 -c 'import sys, json +key = sys.argv[1] +try: + v = json.load(sys.stdin)["result"][key] +except Exception: + sys.exit(1) +sys.exit(0 if isinstance(v, list) else 1)' "$1" 2>/dev/null +} + +# `herdr agent list` → validated JSON on stdout (result.agents is a list). +ha_list() { + ha_have || return 3 + local json + json="$(_ha_bounded "$HA_CALL_TIMEOUT_SECS" herdr agent list 2>/dev/null)" || return 4 + [ -n "$json" ] || return 4 + printf '%s' "$json" | _ha_validate_list agents || return 5 + printf '%s\n' "$json" +} + +# `herdr agent get ` → validated JSON on stdout (has a result object). +ha_get() { + ha_have || return 3 + local target="${1:-}" + _ha_check_target "$target" || return 2 + local json + json="$(_ha_bounded "$HA_CALL_TIMEOUT_SECS" herdr agent get "$target" 2>/dev/null)" || return 4 + [ -n "$json" ] || return 4 + # Explicit if/exit, NOT assert: `python3 -O` / PYTHONOPTIMIZE strips asserts, so + # an assert would silently pass a malformed body through in an optimized runtime. + printf '%s' "$json" | python3 -c 'import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(1) +sys.exit(0 if isinstance(d.get("result"), dict) else 1)' 2>/dev/null || return 5 + printf '%s\n' "$json" +} + +# `herdr agent read [flags…]` — best-effort, per-agent, NEVER format- +# required: whatever herdr prints is forwarded verbatim and herdr's own rc is +# returned. Only the tools-absent guard degrades to code 3. No JSON validation +# by design (read is free-form pane text, not a schema). +ha_read() { + ha_have || return 3 + local target="${1:-}" + _ha_check_target "$target" || return 2 + shift + _ha_bounded "$HA_CALL_TIMEOUT_SECS" herdr agent read "$target" "$@" +} + +# `herdr agent wait --status S [--timeout MS]` — BOUNDED: if the caller +# omits --timeout, inject the default so the call can never block forever. herdr +# blocks server-side until the status is reached or the timeout elapses (no busy +# poll here); its rc is forwarded (0 reached, non-zero timeout/error). +ha_wait() { + ha_have || return 3 + local target="${1:-}" + _ha_check_target "$target" || return 2 + shift + # Detect BOTH `--timeout MS` and `--timeout=MS`, so a caller's explicit bound in + # either form is honoured (no duplicate appended), and capture its value to size + # the client wall-bound below. + local a has_timeout=0 next=0 eff_ms="$HA_WAIT_DEFAULT_TIMEOUT_MS" + for a in "$@"; do + if [ "$next" = 1 ]; then eff_ms="$a"; next=0; continue; fi + case "$a" in + --timeout) has_timeout=1; next=1 ;; + --timeout=*) has_timeout=1; eff_ms="${a#--timeout=}" ;; + esac + done + if [ "$has_timeout" -eq 0 ]; then + set -- "$@" --timeout "$HA_WAIT_DEFAULT_TIMEOUT_MS" + eff_ms="$HA_WAIT_DEFAULT_TIMEOUT_MS" + fi + # Client wall-bound sized ABOVE the server --timeout (+5s margin), so it only + # fires if the server wedges and ignores its own timeout — the "never a hang" + # contract holds for wait too, without cutting a legitimately long wait short. + case "$eff_ms" in ''|*[!0-9]*) eff_ms="$HA_WAIT_DEFAULT_TIMEOUT_MS" ;; esac + _ha_bounded "$(( eff_ms / 1000 + 5 ))" herdr agent wait "$target" "$@" +} + +# ---- CLI dispatch (only when executed directly, never when sourced) --------- +ha_main() { + local cmd="${1:-}" + [ $# -gt 0 ] && shift + case "$cmd" in + list) ha_list ;; + get) ha_get "$@" ;; + read) ha_read "$@" ;; + wait) ha_wait "$@" ;; + *) + echo "usage: ${0##*/} {list | get | read [flags…] | wait --status S [--timeout MS]}" >&2 + return 2 + ;; + esac +} + +# BASH_SOURCE[0] == $0 only when this file is the executed program; when another +# script sources it for $HERDR_MATCH_PRELUDE / the ha_* helpers the two differ, +# so no subcommand runs. +if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ] && [ -n "${BASH_SOURCE[0]:-}" ]; then + set -u # CLI path only — never leaked into a sourcing shell + ha_main "$@" + exit $? +fi diff --git a/plugins/work-system/scripts/herdr-tab-glyph.sh b/plugins/work-system/scripts/herdr-tab-glyph.sh index 55d4a62..4d18f80 100755 --- a/plugins/work-system/scripts/herdr-tab-glyph.sh +++ b/plugins/work-system/scripts/herdr-tab-glyph.sh @@ -48,7 +48,14 @@ # refresh did, which is why it silently never showed up in the sidebar. set -u -SCRIPT_DIR="${0%/*}" +# Resolve via BASH_SOURCE (robust to a bare-name `bash herdr-tab-glyph.sh`, where +# `${0%/*}` would wrongly leave the filename), so the sibling source below and the +# ws-statusline.sh calls always resolve. +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# The realpath cwd↔worktree match lives ONCE in herdr-agent.sh; source it (no +# side effects) for $HERDR_MATCH_PRELUDE, prepended to extract_glyph_tabs below. +. "$SCRIPT_DIR/herdr-agent.sh" # Strip every leading " " so re-prefixing is idempotent even if a prior # bug stacked glyphs. case-prefix matching is byte-exact, so the multibyte @@ -106,15 +113,11 @@ task_glyph() { # dash (first char excludes `-`) so a value like `-x`/`--foo` can never reach # `herdr tab rename` as an option flag; herdr ids are `wN:tM` and never start # with a dash, so nothing legitimate is lost. -extract_glyph_tabs='import sys, json, os, re +extract_glyph_tabs='import sys, json, re main = sys.argv[1] if len(sys.argv) > 1 else "" -if not main.strip(): +root, wtdir = match_roots(main) # from $HERDR_MATCH_PRELUDE (prepended below) +if root is None: sys.exit(0) -root = os.path.realpath(main) -# realpath the whole worktrees path, not just root: cwd is resolved below, so a -# symlinked .claude/worktrees would otherwise never match (dirname of the resolved -# cwd follows the symlink, an unresolved wtdir does not) and task tabs would freeze. -wtdir = os.path.realpath(os.path.join(root, ".claude", "worktrees")) try: agents = json.load(sys.stdin)["result"]["agents"] except Exception: @@ -128,21 +131,19 @@ try: tabs = json.load(fh)["result"]["tabs"] except Exception: sys.exit(0) -labels = {t.get("tab_id"): (t.get("label") or "") for t in tabs} +labels = {t.get("tab_id"): (t.get("label") or "") for t in tabs if isinstance(t, dict)} seen = set() for a in agents: + if not isinstance(a, dict): + continue # non-dict element (e.g. a bare null) — skip, never crash the refresh cwd = (a.get("cwd") or "").rstrip("/") tab = a.get("tab_id") or "" if not cwd or tab in seen: continue if not re.fullmatch(r"[A-Za-z0-9:_.][A-Za-z0-9:_.-]*", tab) or tab not in labels: continue - cwd = os.path.realpath(cwd) - if cwd == root: - kind, key = "main", os.path.basename(root) - elif os.path.dirname(cwd) == wtdir: - kind, key = "task", os.path.basename(cwd) - else: + kind, key, _ = classify_cwd(cwd, root, wtdir) # main | task | (None, None, None) + if kind is None: continue if not key or re.search(r"[\t\r\n]", key): continue @@ -195,9 +196,14 @@ cmd_refresh() { # Empty list output = herdr unreachable (binary present, server down) → # silent no-op, NOT `checked=0` — that line means "reachable, nothing to do". # Agents carry the cwd we match on; tabs carry the label we stamp. Both. - list="$(herdr agent list 2>/dev/null || true)" + # ha_list (from the sourced herdr-agent.sh) bounds the call with a wall-clock + # timeout, so a wedged server can't hang a refresh (runs on /status, /list, …). + list="$(ha_list 2>/dev/null || true)" [ -n "$list" ] || return 0 - tablist="$(herdr tab list 2>/dev/null || true)" + # Bound `herdr tab list` too (via herdr-agent.sh's _ha_bounded, already sourced): + # ha_list guards the agent call, but an unbounded tab-list would still let a + # server that wedges on THIS call hang the refresh — complete the no-hang promise. + tablist="$(_ha_bounded "$HA_CALL_TIMEOUT_SECS" herdr tab list 2>/dev/null || true)" [ -n "$tablist" ] || return 0 # Pass the tab-list JSON by FILE, not as a python argv element: on a big server # it is hundreds of KB and would blow ARG_MAX (E2BIG), a failure the `|| true` @@ -218,7 +224,8 @@ cmd_refresh() { # …) in labels as ASCII and raise UnicodeDecodeError — or fail to *encode* them # on the print — and the bare except / `|| true` would mask it as `checked=0`. tabs="$(printf '%s' "$list" \ - | PYTHONUTF8=1 python3 -c "$extract_glyph_tabs" "$main" "$tabfile" 2>/dev/null || true)" + | PYTHONUTF8=1 python3 -c "$HERDR_MATCH_PRELUDE +$extract_glyph_tabs" "$main" "$tabfile" 2>/dev/null || true)" if [ -z "$tabs" ]; then echo "checked=0 updated=0" return 0 diff --git a/plugins/work-system/scripts/lanes.sh b/plugins/work-system/scripts/lanes.sh new file mode 100755 index 0000000..17148da --- /dev/null +++ b/plugins/work-system/scripts/lanes.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# lanes.sh — the Manager's lane registry: one row per ACTIVE WORKTREE of the +# repo, joining backlog STATE (ws-statusline states) with herdr LIVENESS (herdr +# agent list). A lane's identity is its WORKTREE PATH — pane/tab ids are +# liveness data that churns, never identity (see manager-worker-orchestration). +# +# Usage: lanes.sh [--json] [] +# any path inside the repo (default: $PWD); the main worktree, its +# tasks/ backlog and .claude/worktrees/ are all resolved from it. +# --json emit a JSON array of objects instead of tab-separated rows. +# +# Columns (TSV field order == JSON object keys): +# task worktree branch state glyph agent agent_status pane tab session +# task/worktree/branch — from `git worktree list` (the authoritative lane set) +# state/glyph — from `ws-statusline.sh states` (blank if the task is +# not in the backlog, e.g. archived or /adopt-ed) +# agent…session — from `herdr agent list`, joined on worktree cwd +# +# Liveness degradation (mirrors herdr-teardown's worktree-tab-state tri-state): +# * OUTSIDE a herdr session (HERDR_ENV != 1) → liveness columns BLANK; the +# state/git columns still render. A pure survey, never an error. +# * INSIDE herdr but the agent list is unreachable/empty (repopulating) → +# FAIL-CLOSED: agent_status = "unverified"; never a guessed liveness. +# * INSIDE herdr, populated list, worktree not among the agents → confidently +# no live worker: liveness BLANK. +# +# Always exits 0 for repo/herdr state: a non-repo, or a repo with no task +# worktrees, yields NO rows — empty stdout in TSV, `[]` in --json. (python3 is a +# hard dependency shared by every sibling script; its absence is out of scope for +# the exit-0 promise, same as the rest of the plugin.) +# +# CWD safety: git is addressed with `git -C "$DIR"`; both sides of every cwd +# compare are realpath-resolved (in classify_cwd / the porcelain walk); this +# script never `cd`s. +set -u + +# Resolve the script's own dir via BASH_SOURCE (robust to a bare-name invocation +# like `bash lanes.sh`, where `${0%/*}` would wrongly leave `lanes.sh`), so the +# sibling `source` below always finds herdr-agent.sh. +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# $HERDR_MATCH_PRELUDE (the realpath cwd match) + ha_list live in herdr-agent.sh; +# source it (side-effect free) rather than re-deriving the match here. +. "$SCRIPT_DIR/herdr-agent.sh" + +FORMAT=tsv +[ "${1:-}" = "--json" ] && { FORMAT=json; shift; } +DIR="${1:-$PWD}" + +# The empty result, emitted by every early-exit guard: `[]` in --json (so a +# consumer's json.loads never hits empty stdout), nothing in TSV. Always exit 0. +_empty_exit() { [ "$FORMAT" = json ] && printf '[]\n'; exit 0; } + +# --- lane set + branch: `git worktree list` (authoritative) ------------------ +# LANES_WORKTREES_FILE is a TEST SEAM: it injects porcelain content so the join +# logic is exercisable without a real repo/worktrees. Production reads live git. +if [ -n "${LANES_WORKTREES_FILE:-}" ]; then + WORKTREES="$(cat "$LANES_WORKTREES_FILE")" +else + git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1 || _empty_exit + WORKTREES="$(git -C "$DIR" worktree list --porcelain 2>/dev/null)" +fi +[ -n "$WORKTREES" ] || _empty_exit + +# The main worktree is the first porcelain entry — the backlog + worktrees dir +# live there. Strip "worktree " without field-splitting so a path with spaces +# survives. +MAIN="$(printf '%s\n' "$WORKTREES" | sed -n 's/^worktree //p' | head -1)" +[ -n "$MAIN" ] || _empty_exit + +# --- backlog state: `ws-statusline.sh states --cached` ----------------------- +# --cached = pure survey (read the PR cache, never a synchronous gh call): the +# Manager view must not stall on the network. LANES_STATES_FILE is a test seam. +if [ -n "${LANES_STATES_FILE:-}" ]; then + STATES="$(cat "$LANES_STATES_FILE")" +else + STATES="$(bash "$SCRIPT_DIR/ws-statusline.sh" states --cached "$DIR" 2>/dev/null || true)" +fi + +# --- herdr liveness ---------------------------------------------------------- +# LIVENESS_MODE drives the degrade policy in the join: +# absent → blank liveness (we are OUTSIDE a herdr session; do not check) +# unverified → fail-closed (INSIDE herdr but the list was unreachable) +# list → parse $AGENTS_FILE (the join refines empty/malformed → unverified) +# LANES_AGENTS_FILE is a test seam that forces the `list` path with fixed JSON. +AGENTS_FILE="" +LIVENESS_MODE=absent +if [ -n "${LANES_AGENTS_FILE:-}" ]; then + AGENTS_FILE="$LANES_AGENTS_FILE" + LIVENESS_MODE=list +elif [ "${HERDR_ENV:-}" = "1" ]; then + LIVENESS_MODE=unverified # inside herdr: fail-closed until we have a list + if agents_json="$(ha_list)"; then + AGENTS_FILE="$(mktemp "${TMPDIR:-/tmp}/lanes-agents.XXXXXX")" || AGENTS_FILE="" + if [ -n "$AGENTS_FILE" ]; then + # EXIT trap (not a trailing rm): a signal between mktemp and cleanup would + # otherwise orphan the temp file. ${AGENTS_FILE:-} keeps it safe under set -u. + trap 'rm -f "${AGENTS_FILE:-}"' EXIT + printf '%s' "$agents_json" > "$AGENTS_FILE" + LIVENESS_MODE=list + fi + fi +fi + +# --- the join ---------------------------------------------------------------- +# stdin = the worktree porcelain (the lane set + branch) +# argv = main, format, liveness-mode, agents-file, states-blob +# The prelude (match_roots / classify_cwd) is prepended so the agent→worktree +# match is the SAME one herdr-tab-glyph.sh uses. +lanes_join='import sys, json +main = sys.argv[1] +fmt = sys.argv[2] +mode = sys.argv[3] +agents_file = sys.argv[4] +states_blob = sys.argv[5] if len(sys.argv) > 5 else "" + +# realpath helpers come from the prepended $HERDR_MATCH_PRELUDE (it imports os). +def _s(v): + # Coerce any cell value to a string: herdr fields are untrusted and may be + # non-string JSON (e.g. a numeric agent_status), which would crash the TSV + # scrub or type-mismatch the JSON. None -> "". + return "" if v is None else str(v) + +root, wtdir = match_roots(main) +if root is None: + if fmt == "json": + print("[]") + sys.exit(0) + +# state + glyph by task name (ws-statusline states emits task\tstate\tglyph) +state_of = {} +for line in states_blob.splitlines(): + p = line.split("\t") + if len(p) >= 3: + state_of[p[0]] = (p[1], p[2]) + +# liveness by worktree realpath (first agent in a worktree wins). mode may be +# demoted to "unverified" here when the list is malformed/empty (fail-closed). +live = {} +saw_malformed = False # a non-dict element seen → the list is partly untrustworthy +if mode == "list" and agents_file: + try: + agents = json.load(open(agents_file))["result"]["agents"] + except Exception: + agents = None + if agents is None: + mode = "unverified" # malformed → do not guess + elif not agents: + mode = "unverified" # empty/repopulating list → do not guess + else: + for a in agents: + if not isinstance(a, dict): + saw_malformed = True # non-dict element (e.g. a bare null) — skip, never crash + continue + kind, key, wt = classify_cwd(a.get("cwd"), root, wtdir) + if kind != "task": + continue + if wt in live: # first agent in a worktree wins + continue + sess = "" + s = a.get("agent_session") + if isinstance(s, dict): + sess = _s(s.get("value")) + live[wt] = { + "agent": _s(a.get("agent")), + "agent_status": _s(a.get("agent_status")), + "pane": _s(a.get("pane_id")), + "tab": _s(a.get("tab_id")), + "session": sess, + } + +# lane set from the porcelain: keep only worktrees classify_cwd calls "task" +# (drops the main worktree and any external/manual worktree) — the SAME shared +# classification the liveness join and herdr-tab-glyph.sh use, so the rule can +# never drift between the two consumers. +lanes = [] +cur_path = None +cur_branch = "" +def flush(): + global cur_path, cur_branch + if cur_path is not None: + kind, key, rp = classify_cwd(cur_path, root, wtdir) + if kind == "task": + lanes.append((key, rp, cur_branch)) + cur_path = None + cur_branch = "" +for line in sys.stdin.read().splitlines(): + if line.startswith("worktree "): + flush() + cur_path = line[len("worktree "):] + elif line.startswith("branch refs/heads/"): + cur_branch = line[len("branch refs/heads/"):] + elif line == "detached": + cur_branch = "" +flush() +lanes.sort(key=lambda t: t[0]) + +def liveness_for(wt): + if wt in live: + L = live[wt] + return (L["agent"], L["agent_status"], L["pane"], L["tab"], L["session"]) + # A matched lane keeps its confident values above. For an UNMATCHED lane, a + # list that carried a malformed element can no longer be fully trusted to + # assert "no worker here" (the junk element may have been this lane agent) — + # fail closed to unverified, same as an unreachable/empty list. + if mode == "unverified" or saw_malformed: + return ("", "unverified", "", "", "") + return ("", "", "", "", "") # absent (outside herdr) OR present-but-no-agent + +COLS = ["task", "worktree", "branch", "state", "glyph", + "agent", "agent_status", "pane", "tab", "session"] +rows = [] +for task, wt, branch in lanes: + state, glyph = state_of.get(task, ("", "")) + agent, astatus, pane, tab, session = liveness_for(wt) + rows.append({"task": task, "worktree": wt, "branch": branch, + "state": state, "glyph": glyph, "agent": agent, + "agent_status": astatus, "pane": pane, "tab": tab, + "session": session}) + +if fmt == "json": + print(json.dumps(rows, ensure_ascii=False)) +else: + # Scrub tab/CR/LF from every cell before emitting TSV: agent/agent_status/ + # pane/tab/session are UNTRUSTED herdr fields (any tool in the session can set + # them), so an embedded tab/newline would forge extra columns/rows and a + # consumer cut -f9/f10 would aim a herdr op at the wrong id. Mirrors the same + # scrub herdr-tab-glyph.sh applies to untrusted label/key fields. (--json is + # already safe — json.dumps quotes the control chars.) + import re + def _cell(s): + return re.sub(r"[\t\r\n]", " ", s) + for r in rows: + print("\t".join(_cell(r[c]) for c in COLS))' + +printf '%s' "$WORKTREES" \ + | PYTHONUTF8=1 python3 -c "$HERDR_MATCH_PRELUDE +$lanes_join" "$MAIN" "$FORMAT" "$LIVENESS_MODE" "$AGENTS_FILE" "$STATES" diff --git a/plugins/work-system/scripts/test_herdr_agent.py b/plugins/work-system/scripts/test_herdr_agent.py new file mode 100644 index 0000000..70a3495 --- /dev/null +++ b/plugins/work-system/scripts/test_herdr_agent.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Tests for herdr-agent.sh — the wrapper over the herdr agent primitives. + +A fake `herdr` on PATH returns canned JSON/text per subcommand, so these run the +REAL herdr-agent.sh end to end (the exact surface lanes.sh and the future +Manager tooling call). Covers: list validation (ok / malformed → 5 / herdr +failure → 4), the tools-absent degrade (→ 3, no hang), get validation, read as +best-effort passthrough (herdr rc + text forwarded, no schema), and that wait is +BOUNDED — a caller-omitted --timeout is injected, a caller-supplied one is left +alone. +""" +import json as jsonlib +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).parent +SCRIPT = HERE / "herdr-agent.sh" + +FAILS = [] + + +def check(name, cond): + if not cond: + FAILS.append(name) + + +def shquote(s): + return "'" + s.replace("'", "'\\''") + "'" + + +def herdr_stub(cases): + """{' ': (stdout, stderr, exit)} → a fake `herdr` script. With + $HERDR_ARGV_LOG set it appends every received arg (one per line) so a test + can assert the exact argv herdr-agent.sh execs.""" + lines = [ + "#!/usr/bin/env bash", + 'if [ -n "${HERDR_ARGV_LOG:-}" ]; then', + ' printf \'%s\\n\' "$@" >> "$HERDR_ARGV_LOG"', + "fi", + 'case "$1 $2" in', + ] + for key, (out, err, rc) in cases.items(): + lines.append(f' "{key}")') + if out: + lines.append(f" printf '%s' {shquote(out)}") + if err: + lines.append(f" printf '%s' {shquote(err)} >&2") + lines.append(f" exit {rc}") + lines.append(" ;;") + lines.append(' *) echo "unhandled herdr stub call: $*" >&2; exit 9 ;;') + lines.append("esac") + return "\n".join(lines) + + +class Env: + def __init__(self, cases, log_argv=False): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + bindir = root / "bin" + bindir.mkdir() + herdr = bindir / "herdr" + herdr.write_text(herdr_stub(cases)) + herdr.chmod(0o755) + self.env = dict(os.environ) + self.env["PATH"] = f"{bindir}:{self.env['PATH']}" + self.argv_log = root / "argv.log" if log_argv else None + if self.argv_log is not None: + self.env["HERDR_ARGV_LOG"] = str(self.argv_log) + + def logged_argv(self): + if self.argv_log is None or not self.argv_log.exists(): + return [] + return self.argv_log.read_text().splitlines() + + def run(self, *args, path=None): + env = dict(self.env) + if path is not None: + env["PATH"] = path + return subprocess.run( + ["bash", str(SCRIPT), *args], + env=env, capture_output=True, text=True, timeout=20, + ) + + def close(self): + self.tmp.cleanup() + + +AGENTS = jsonlib.dumps({"result": {"agents": [{"agent": "claude", "cwd": "/x"}]}}) + +# --- list: valid JSON passes through, exit 0 ------------------------------- # +e = Env({"agent list": (AGENTS, "", 0)}) +r = e.run("list") +check("list: exit 0", r.returncode == 0) +check("list: JSON forwarded", jsonlib.loads(r.stdout)["result"]["agents"][0]["agent"] == "claude") +e.close() + +# --- list: malformed JSON → code 5, empty stdout --------------------------- # +e = Env({"agent list": ("not json{", "", 0)}) +r = e.run("list") +check("list: malformed → 5", r.returncode == 5) +check("list: malformed → no stdout", r.stdout == "") +e.close() + +# --- list: valid JSON but wrong shape (agents not a list) → code 5 --------- # +e = Env({"agent list": (jsonlib.dumps({"result": {"agents": {}}}), "", 0)}) +r = e.run("list") +check("list: wrong-shape → 5", r.returncode == 5) +e.close() + +# --- list: herdr itself fails (rc != 0) → code 4 --------------------------- # +e = Env({"agent list": ("", "boom", 1)}) +r = e.run("list") +check("list: herdr failure → 4", r.returncode == 4) +e.close() + +# --- tools absent (no herdr on PATH) → code 3, no hang --------------------- # +e = Env({"agent list": (AGENTS, "", 0)}) +r = e.run("list", path="/usr/bin:/bin") +check("degrade: no herdr → 3", r.returncode == 3) +check("degrade: no stdout", r.stdout == "") +e.close() + +# --- get: validates a result object ---------------------------------------- # +e = Env({"agent get": (jsonlib.dumps({"result": {"agent": {"pane_id": "w1:p1"}}}), "", 0)}) +r = e.run("get", "w1:p1") +check("get: exit 0", r.returncode == 0) +check("get: JSON forwarded", jsonlib.loads(r.stdout)["result"]["agent"]["pane_id"] == "w1:p1") +e.close() + +# --- get: malformed → code 5 ----------------------------------------------- # +e = Env({"agent get": ("nope", "", 0)}) +r = e.run("get", "w1:p1") +check("get: malformed → 5", r.returncode == 5) +e.close() + +# --- read: best-effort passthrough (free-form text + herdr rc, no schema) -- # +e = Env({"agent read": ("some pane text, not json\n", "", 0)}) +r = e.run("read", "w1:p1") +check("read: exit 0", r.returncode == 0) +check("read: text forwarded verbatim", r.stdout == "some pane text, not json\n") +e.close() + +# read forwards herdr's non-zero rc unchanged (best-effort, never masks failure) +e = Env({"agent read": ("", "gone", 4)}) +r = e.run("read", "w1:pX") +check("read: herdr rc forwarded", r.returncode == 4) +e.close() + +# --- wait: caller omits --timeout → the default is injected (bounded) ------ # +e = Env({"agent wait": ("", "", 0)}, log_argv=True) +r = e.run("wait", "w1:p1", "--status", "idle") +argv = e.logged_argv() +check("wait: exit 0", r.returncode == 0) +check("wait: --timeout injected when omitted", "--timeout" in argv) +check("wait: default value injected", "10000" in argv) +e.close() + +# --- wait: caller's own --timeout is preserved, not doubled ---------------- # +e = Env({"agent wait": ("", "", 0)}, log_argv=True) +r = e.run("wait", "w1:p1", "--status", "idle", "--timeout", "500") +argv = e.logged_argv() +check("wait: caller timeout preserved", "500" in argv) +check("wait: default not appended over caller's", "10000" not in argv) +check("wait: exactly one --timeout", argv.count("--timeout") == 1) +e.close() + +# --- wait: the --timeout=MS form is honored too (no default appended) ------ # +e = Env({"agent wait": ("", "", 0)}, log_argv=True) +r = e.run("wait", "w1:p1", "--status", "idle", "--timeout=500") +argv = e.logged_argv() +check("wait: --timeout= form detected", "--timeout=500" in argv) +check("wait: default not appended over --timeout= form", "10000" not in argv) +e.close() + +# --- missing is a usage error (2), NOT server-unreachable (4) ----- # +for sub in ("get", "read", "wait"): + e = Env({f"agent {sub}": ("", "", 0)}) + r = e.run(sub) # no target argument + check(f"{sub}: missing target → exit 2", r.returncode == 2) + e.close() + +# --- a leading-dash is rejected (never passed as an option flag) --- # +for sub in ("get", "read", "wait"): + e = Env({f"agent {sub}": ("", "", 0)}) + r = e.run(sub, "-x") + check(f"{sub}: leading-dash target → exit 2", r.returncode == 2) + e.close() + +# --- wait injects a client wall-bound sized above the server --timeout ------ # +# The fake herdr still receives the herdr args verbatim through the bounding +# wrapper, so the arg contract is unchanged; this just confirms it still runs. +e = Env({"agent wait": ("", "", 0)}, log_argv=True) +r = e.run("wait", "w1:p1", "--status", "idle") +check("wait: still exits 0 through the wall-bound wrapper", r.returncode == 0) +check("wait: herdr args reach the server through the bound", "wait" in e.logged_argv()) +e.close() + + +if FAILS: + print("FAIL:") + for f in FAILS: + print(" -", f) + sys.exit(1) +print("herdr-agent.sh: all tests passed") diff --git a/plugins/work-system/scripts/test_lanes.py b/plugins/work-system/scripts/test_lanes.py new file mode 100644 index 0000000..16bb732 --- /dev/null +++ b/plugins/work-system/scripts/test_lanes.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Tests for lanes.sh — the lane registry join. + +lanes.sh exposes three TEST SEAMS (env vars) so the join is exercisable without +a real repo/herdr: LANES_WORKTREES_FILE (porcelain), LANES_STATES_FILE +(task\\tstate\\tglyph), LANES_AGENTS_FILE (agent-list JSON → forces the `list` +liveness path). These drive the REAL lanes.sh end to end. + +Covers the three required paths — states⨝liveness JOIN, the DEGRADED path +(outside herdr → blank liveness), the UNVERIFIED path (inside herdr, empty list +→ fail-closed) — plus: first-agent-in-a-worktree wins, main + external worktrees +are excluded, malformed list → unverified, and a backlog-less worktree gets +blank state. +""" +import json as jsonlib +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).parent +SCRIPT = HERE / "lanes.sh" + +FAILS = [] + + +def check(name, cond): + if not cond: + FAILS.append(name) + + +ROOT = "/lanes-test-root" # fake, need not exist — realpath just normalizes it +WT = f"{ROOT}/.claude/worktrees" + +# main + two task worktrees + one EXTERNAL worktree (not under .claude/worktrees) +PORCELAIN = "\n".join([ + f"worktree {ROOT}", + "HEAD aaaa", + "branch refs/heads/main", + "", + f"worktree {WT}/alpha", + "HEAD bbbb", + "branch refs/heads/task/alpha", + "", + f"worktree {WT}/beta", + "HEAD cccc", + "branch refs/heads/task/beta", + "", + f"worktree {WT}/gamma", # a worktree with NO backlog task (archived/adopted) + "HEAD gggg", + "branch refs/heads/task/gamma", + "", + f"worktree {ROOT}/external-wt", + "HEAD dddd", + "branch refs/heads/misc", + "", +]) + +STATES = "alpha\tactive\t●\nbeta\treview\t◇\n" + + +def run(agents_json=None, herdr_env=False, fmt="--json", worktrees=PORCELAIN): + """Run lanes.sh with injected worktrees/states and (optionally) an agent + list. Returns parsed JSON rows keyed by task.""" + env = dict(os.environ) + env.pop("HERDR_ENV", None) + if herdr_env: + env["HERDR_ENV"] = "1" + tmp = tempfile.TemporaryDirectory() + d = Path(tmp.name) + (d / "wt").write_text(worktrees) + (d / "states").write_text(STATES) + env["LANES_WORKTREES_FILE"] = str(d / "wt") + env["LANES_STATES_FILE"] = str(d / "states") + if agents_json is not None: + (d / "agents").write_text(agents_json) + env["LANES_AGENTS_FILE"] = str(d / "agents") + else: + env.pop("LANES_AGENTS_FILE", None) + args = ["bash", str(SCRIPT)] + if fmt: + args.append(fmt) + r = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20) + tmp.cleanup() + if fmt == "--json" and r.stdout.strip(): + return r, {row["task"]: row for row in jsonlib.loads(r.stdout)} + return r, {} + + +# --- exclusion: only the three .claude/worktrees children are lanes -------- # +r, rows = run() +check("lane set: alpha+beta+gamma (main + external dropped)", + set(rows) == {"alpha", "beta", "gamma"}) +check("exit 0", r.returncode == 0) + +# --- state join: state/glyph land regardless of liveness ------------------- # +check("state: alpha active", rows["alpha"]["state"] == "active" and rows["alpha"]["glyph"] == "●") +check("state: beta review", rows["beta"]["state"] == "review" and rows["beta"]["glyph"] == "◇") +check("branch carried", rows["alpha"]["branch"] == "task/alpha") + +# --- DEGRADED (outside herdr, no agent list): liveness blank --------------- # +check("degraded: alpha agent blank", rows["alpha"]["agent"] == "") +check("degraded: alpha status blank", rows["alpha"]["agent_status"] == "") +check("degraded: alpha pane/tab/session blank", + rows["alpha"]["pane"] == "" and rows["alpha"]["tab"] == "" and rows["alpha"]["session"] == "") + +# --- JOIN: a populated list attaches liveness; first agent in a wt wins ----- # +agents = jsonlib.dumps({"result": {"agents": [ + {"agent": "claude", "agent_status": "working", "cwd": f"{WT}/alpha", + "pane_id": "w1:p9", "tab_id": "w1:t9", "agent_session": {"value": "UUID-A"}}, + {"agent": "codex", "agent_status": "idle", "cwd": f"{WT}/alpha", + "pane_id": "w1:pX", "tab_id": "w1:tX", "agent_session": {"value": "UUID-B"}}, +]}}) +r, rows = run(agents_json=agents, herdr_env=True) +check("join: alpha agent=claude (first wins)", rows["alpha"]["agent"] == "claude") +check("join: alpha status=working", rows["alpha"]["agent_status"] == "working") +check("join: alpha pane/tab from first agent", + rows["alpha"]["pane"] == "w1:p9" and rows["alpha"]["tab"] == "w1:t9") +check("join: alpha session UUID-A", rows["alpha"]["session"] == "UUID-A") +check("join: second agent (codex) ignored", rows["alpha"]["session"] != "UUID-B") +# beta has NO agent while the list is populated → confidently blank, NOT unverified +check("join: beta blank (no agent, list populated)", + rows["beta"]["agent"] == "" and rows["beta"]["agent_status"] == "") +check("join: beta keeps its state", rows["beta"]["state"] == "review") + +# --- UNVERIFIED: inside herdr but the list is empty (repopulating) --------- # +r, rows = run(agents_json=jsonlib.dumps({"result": {"agents": []}}), herdr_env=True) +check("unverified: alpha agent_status=unverified", rows["alpha"]["agent_status"] == "unverified") +check("unverified: beta agent_status=unverified", rows["beta"]["agent_status"] == "unverified") +check("unverified: agent name still blank", rows["alpha"]["agent"] == "") +check("unverified: state still joined", rows["alpha"]["state"] == "active") + +# --- UNVERIFIED: a malformed list is fail-closed, never guessed ------------ # +r, rows = run(agents_json="broken{", herdr_env=True) +check("malformed: alpha unverified", rows["alpha"]["agent_status"] == "unverified") + +# --- backlog-less worktree (gamma): still a lane, blank state/glyph -------- # +r, rows = run() +check("backlog-less: gamma is a lane", "gamma" in rows) +check("backlog-less: gamma state blank", rows["gamma"]["state"] == "") +check("backlog-less: gamma glyph blank", rows["gamma"]["glyph"] == "") +check("backlog-less: gamma branch still carried", rows["gamma"]["branch"] == "task/gamma") + +# --- TSV output shape: 10 tab-separated columns per lane ------------------- # +r, _ = run(fmt=None) # default TSV +tsv_lines = [ln for ln in r.stdout.splitlines() if ln] +check("tsv: three rows", len(tsv_lines) == 3) +check("tsv: 10 columns", all(len(ln.split("\t")) == 10 for ln in tsv_lines)) + +# --- null / non-dict agent element: no crash; matched keeps value, UNMATCHED -- +# lanes fail closed to unverified (the list can't be fully trusted) -------- # +mixed = jsonlib.dumps({"result": {"agents": [ + None, + {"agent": "claude", "agent_status": "working", "cwd": f"{WT}/alpha", + "pane_id": "w1:p9", "tab_id": "w1:t9", "agent_session": {"value": "U"}}, +]}}) +r, rows = run(agents_json=mixed, herdr_env=True) +check("null-element: exit 0 (no crash)", r.returncode == 0) +check("null-element: matched lane keeps confident value", rows["alpha"]["agent"] == "claude") +check("null-element: unmatched lane fails closed to unverified", + rows["beta"]["agent_status"] == "unverified") + +# --- non-string agent field: coerced, never crashes the TSV scrub ---------- # +nonstr = jsonlib.dumps({"result": {"agents": [ + {"agent": "claude", "agent_status": 7, "cwd": f"{WT}/alpha", + "pane_id": "w1:p9", "tab_id": "w1:t9", "agent_session": {"value": "U"}}, +]}}) +r, _ = run(agents_json=nonstr, herdr_env=True, fmt=None) # TSV path +check("non-string field: exit 0 (no TypeError)", r.returncode == 0) +r, rows = run(agents_json=nonstr, herdr_env=True) +check("non-string field: coerced to str in JSON", rows["alpha"]["agent_status"] == "7") + +# --- TSV field-injection: tab/newline in untrusted fields is scrubbed ------ # +inj = jsonlib.dumps({"result": {"agents": [ + {"agent": "claude", "agent_status": "working\tfake\nrow", "cwd": f"{WT}/alpha", + "pane_id": "w1:p9", "tab_id": "w1:t9", "agent_session": {"value": "U"}}, +]}}) +r, _ = run(agents_json=inj, herdr_env=True, fmt=None) # TSV path +inj_lines = [ln for ln in r.stdout.splitlines() if ln] +check("tsv-injection: row count unchanged (no forged line)", len(inj_lines) == 3) +check("tsv-injection: every row still 10 columns", all(len(ln.split("\t")) == 10 for ln in inj_lines)) + +# --- --json early-exit paths emit [] (never empty stdout) ------------------ # +r, _ = run(worktrees="", fmt="--json") # empty worktree porcelain +check("json-empty: exit 0", r.returncode == 0) +check("json-empty: prints [] not empty", r.stdout.strip() == "[]") +r, _ = run(worktrees="", fmt=None) # TSV counterpart prints nothing +check("tsv-empty: prints nothing", r.stdout.strip() == "") + + +# --- INTEGRATION: the production HERDR_ENV → ha_list → mktemp → trap → join +# glue (NOT the LANES_AGENTS_FILE seam), via a fake `herdr` on PATH -------- # +def run_with_fake_herdr(agents_json, worktrees=PORCELAIN): + env = dict(os.environ) + env["HERDR_ENV"] = "1" + tmp = tempfile.TemporaryDirectory() + d = Path(tmp.name) + (d / "wt").write_text(worktrees) + (d / "states").write_text(STATES) + env["LANES_WORKTREES_FILE"] = str(d / "wt") + env["LANES_STATES_FILE"] = str(d / "states") + env.pop("LANES_AGENTS_FILE", None) # force the real ha_list branch, not the seam + bindir = d / "bin" + bindir.mkdir() + herdr = bindir / "herdr" + herdr.write_text( + "#!/usr/bin/env bash\n" + 'if [ "$1 $2" = "agent list" ]; then\n' + " cat <<'JSON'\n" + agents_json + "\nJSON\n" + " exit 0\n" + "fi\n" + "exit 9\n" + ) + herdr.chmod(0o755) + env["PATH"] = f"{bindir}:{env['PATH']}" + r = subprocess.run(["bash", str(SCRIPT), "--json"], env=env, + capture_output=True, text=True, timeout=20) + tmp.cleanup() + rows = ({row["task"]: row for row in jsonlib.loads(r.stdout)} + if r.stdout.strip() else {}) + return r, rows + + +live = jsonlib.dumps({"result": {"agents": [ + {"agent": "claude", "agent_status": "working", "cwd": f"{WT}/alpha", + "pane_id": "w1:p9", "tab_id": "w1:t9", "agent_session": {"value": "LIVE"}}, +]}}) +r, rows = run_with_fake_herdr(live) +check("integration: exit 0", r.returncode == 0) +check("integration: ha_list path joins liveness", rows.get("alpha", {}).get("agent") == "claude") +check("integration: session from the live path", rows.get("alpha", {}).get("session") == "LIVE") +check("integration: unmatched lane blank (list populated)", + rows.get("beta", {}).get("agent_status") == "") + + +if FAILS: + print("FAIL:") + for f in FAILS: + print(" -", f) + sys.exit(1) +print("lanes.sh: all tests passed")