Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ Rules a change must keep:
- `ui::draw` reads no file, runs no program and asks no clock, so a `TestBackend` case is an
assertion about the screen and not about the machine.
- A decision that also has a bash implementation is held to a shared table under `tests/lib/`
(`session-args.cases`, `sweep-findings.json`, `triage.cases`, `sweep-tell.cases`).
(`session-args.cases`, `sweep-findings.json`, `triage.cases`, `sweep-tell.cases`,
`epic-family.json`).
- Const thresholds in Rust (`END_GRACE_SECONDS`, `STUCK_CEILING_SECONDS`, …) have a literal twin
in the scripts; change both.

Expand Down Expand Up @@ -239,7 +240,9 @@ Each of these answers one question in one place. Add a caller, never a second co
the fleet view's Sweeps section finds, each held to `tests/lib/sweep-findings.json`;
`scripts/verifier-epic-candidates` — when an epic family is Psylocke's to sweep.
- `scripts/bead-delivery.sh` — whether a bead's work reached the default branch.
- `scripts/work-beads` — the board read, and the epic rule.
- `scripts/work-beads` — the board read, and the epic rule. Which beads under an epic are its
family (an `event` never is) is held to `tests/lib/epic-family.json`, which it,
`verifier-epic-candidates`, `sweep-epics.sh` and the fleet view's `model.rs` all answer.
- `scripts/worktree-safety.sh` — whether a worktree can go without losing anything.
- `scripts/session-marker.sh` — the marker sentence; `scripts/marker-readers` checks every
reader subscribes to `tests/lib/session-args.cases`.
Expand Down
69 changes: 69 additions & 0 deletions fleet-view/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,75 @@ mod tests {
);
}

/// One row of `tests/lib/epic-family.json`: an epic, everything bd files under it, and which
/// of those are its family. The bash readers' expectation fields this crate has no use for
/// (`last_child_closed_at`) are ignored.
#[derive(Deserialize)]
struct EpicFamilyRow {
name: String,
epic: EpicFamilyBead,
under: Vec<EpicFamilyBead>,
family: Vec<String>,
family_closed: bool,
}

#[derive(Deserialize)]
struct EpicFamilyBead {
id: String,
status: String,
#[serde(default = "epic_issue_type")]
issue_type: String,
}

fn epic_issue_type() -> String {
"epic".into()
}

/// The rule the fleet view, `scripts/work-beads`, `scripts/verifier-epic-candidates` and
/// `scripts/sweep-epics.sh` all answer, row by row: an `event` under an epic is never its
/// child (cb-cng9, cb-z330, cb-u64g - one defect, three fixes, hence the one table, cb-7iv1).
/// Here the family decides three things: whether the epic is in `epics`, whether an open epic
/// is work of its own (unplanned), and whether a closed one reaches Merged.
#[test]
fn the_epic_family_table_is_answered() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/lib/epic-family.json");
let text =
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("epic-family.json: {e}"));
let values: Vec<serde_json::Value> =
serde_json::from_str(&text).expect("epic-family.json is a JSON array");
let rows: Vec<EpicFamilyRow> = values
.into_iter()
// An element carrying `_` is the file's own comment - JSON has none.
.filter(|value| value.get("_").is_none())
.map(|value| serde_json::from_value(value).expect("a well-formed row"))
.collect();
assert!(!rows.is_empty(), "epic-family.json has no rows");

for row in rows {
let epic_id = row.epic.id.clone();
let epic_open = row.epic.status == "open";
let beads = std::iter::once(&row.epic)
.chain(&row.under)
.map(|b| bead(&b.id, &b.status, &b.issue_type, &[]))
.collect();
let buckets = partition_beads(beads);
let childless = row.family.is_empty();
let has = |list: &[Bead]| list.iter().any(|b| b.id == epic_id);

assert_eq!(buckets.epics.contains_key(&epic_id), !childless, "{}: in epics", row.name);
if epic_open {
assert_eq!(has(&buckets.unplanned), childless, "{}: open epic as work", row.name);
} else {
assert_eq!(
has(&buckets.merged),
childless || row.family_closed,
"{}: closed epic in Merged",
row.name
);
}
}
}

/// The set is DIRECT parents, not ancestors: everything before an id's LAST dot. A
/// `starts_with("cb-p.")` spelling would call `cb-p` parented by its grandchild and skip it.
#[test]
Expand Down
7 changes: 5 additions & 2 deletions scripts/sweep-epics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ while IFS= read -r epic; do

children_json="$(bd -C "$repo_root" children "$id" --json 2>/dev/null || echo "[]")"
# The newest `closed_at` among closed children - eligibility already means every child is closed,
# so this is "when did the last one land", not "are they all closed".
last_closed="$(jq -r '[.[] | select(.status == "closed") | .closed_at // empty] | sort | last // empty' <<<"$children_json")"
# so this is "when did the last one land", not "are they all closed". `bd children` also returns
# the `event` beads `bd set-state` files under the epic; they are not children, and one closed
# after the last real child would make the close look fresh and hold the offer back. The rule
# every implementation answers is tests/lib/epic-family.json (cb-cng9, cb-z330, cb-u64g, cb-7iv1).
last_closed="$(jq -r '[.[] | select(.issue_type != "event" and .status == "closed") | .closed_at // empty] | sort | last // empty' <<<"$children_json")"

minutes=null
if [[ -n "$last_closed" ]]; then
Expand Down
77 changes: 77 additions & 0 deletions tests/lib/epic-family.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
[
{
"_": "Which beads under an epic are its family. `bd set-state` files its audit record as an `event` bead under the bead it changed, with a dotted child id, and `bd children` returns it - but an event is never a child. That rule reached main broken three times, each time fixed in one implementation only (cb-z330, cb-cng9, cb-u64g), so every implementation answers this table: tests/work-beads.sh, tests/verifier-epic-candidates.sh, tests/sweep-epics.sh and model::tests::the_epic_family_table_is_answered in fleet-view/src/model.rs. `under` is everything bd files under the epic, as `bd children` returns it. The three expectations are stated, not derived, so no reader re-implements the rule in its own test: `family` the ids that ARE children (no implementation exposes membership, so every reader checks only whether it is empty; the ids say which beads count, for the reader of a row); `family_closed` true when the epic has a family and every member is closed; `last_child_closed_at` the newest `closed_at` in the family, or null. An element carrying this `_` key is a comment and is skipped by every reader."
},
{
"name": "an unsplit epic with one state change is still childless",
"epic": { "id": "ef-a", "status": "open" },
"under": [
{ "id": "ef-a.1", "issue_type": "event", "status": "closed", "closed_at": "2026-01-05T10:00:00Z" }
],
"family": [],
"family_closed": false,
"last_child_closed_at": null
},
{
"name": "a closed epic whose only bead under it is an event is childless",
"epic": { "id": "ef-b", "status": "closed" },
"under": [
{ "id": "ef-b.1", "issue_type": "event", "status": "closed", "closed_at": "2026-01-05T10:00:00Z" }
],
"family": [],
"family_closed": false,
"last_child_closed_at": null
},
{
"name": "an event newer than the last real child does not make the last close fresh",
"epic": { "id": "ef-c", "status": "open" },
"under": [
{ "id": "ef-c.1", "issue_type": "task", "status": "closed", "closed_at": "2026-01-02T08:00:00Z" },
{ "id": "ef-c.2", "issue_type": "event", "status": "closed", "closed_at": "2026-01-09T08:00:00Z" }
],
"family": ["ef-c.1"],
"family_closed": true,
"last_child_closed_at": "2026-01-02T08:00:00Z"
},
{
"name": "an open event does not hold back a family whose children are all closed",
"epic": { "id": "ef-d", "status": "closed" },
"under": [
{ "id": "ef-d.1", "issue_type": "feature", "status": "closed", "closed_at": "2026-01-03T12:00:00Z" },
{ "id": "ef-d.2", "issue_type": "event", "status": "open", "closed_at": null }
],
"family": ["ef-d.1"],
"family_closed": true,
"last_child_closed_at": "2026-01-03T12:00:00Z"
},
{
"name": "a closed event does not close a family whose real child is open",
"epic": { "id": "ef-e", "status": "open" },
"under": [
{ "id": "ef-e.1", "issue_type": "task", "status": "open", "closed_at": null },
{ "id": "ef-e.2", "issue_type": "event", "status": "closed", "closed_at": "2026-01-04T09:00:00Z" }
],
"family": ["ef-e.1"],
"family_closed": false,
"last_child_closed_at": null
},
{
"name": "an epic with nothing under it is childless",
"epic": { "id": "ef-f", "status": "open" },
"under": [],
"family": [],
"family_closed": false,
"last_child_closed_at": null
},
{
"name": "two real children and no event: the newer close is the last",
"epic": { "id": "ef-g", "status": "closed" },
"under": [
{ "id": "ef-g.1", "issue_type": "task", "status": "closed", "closed_at": "2026-01-02T08:00:00Z" },
{ "id": "ef-g.2", "issue_type": "bug", "status": "closed", "closed_at": "2026-01-06T15:30:00Z" }
],
"family": ["ef-g.1", "ef-g.2"],
"family_closed": true,
"last_child_closed_at": "2026-01-06T15:30:00Z"
}
]
102 changes: 102 additions & 0 deletions tests/sweep-epics.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
#
# Proves scripts/sweep-epics.sh: every epic `bd epic status --eligible-only` calls closable, with
# the minutes since its last child closed, which the fleet view judges against its threshold
# (tests/lib/sweep-findings.json holds that judgement; this suite holds the script's facts).
#
# The fact that has broken is "last child": `bd children` also returns the `event` beads
# `bd set-state` files under an epic, and an event that closed after the last real child made the
# close look fresh and held the offer back. tests/lib/epic-family.json is the rule every
# implementation answers (cb-7iv1); here, `minutes_since_last_child_closed` is measured from the
# row's `last_child_closed_at`, and is null when that is.
#
# No framework: plain bash, set -euo pipefail, exit non-zero on the first failed assertion. Run
# from the submodule root:
#
# bash tests/sweep-epics.sh

set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# fail, pass, $work_dir and its cleanup trap - see tests/lib/consumer.sh.
source "$repo_root/tests/lib/consumer.sh"

# A stub `bd` on PATH ahead of the real one, which would read this machine's own backlog. It
# answers `epic status` from one file and `children` from another: one epic per case.
stub_dir="$work_dir/bin"
mkdir -p "$stub_dir"
epics_file="$work_dir/epics.json"
children_file="$work_dir/children.json"
cat > "$stub_dir/bd" <<STUB
#!/usr/bin/env bash
for a in "\$@"; do
case "\$a" in
epic) cat "$epics_file"; exit 0 ;;
children) cat "$children_file"; exit 0 ;;
esac
done
echo "stub bd: unexpected call: \$*" >&2
exit 1
STUB
chmod +x "$stub_dir/bd"
export PATH="$stub_dir:$PATH"

consumer="$(consumer_new repo --link consumer-root sweep-epics.sh)"
sweep="$consumer/.cerebro/cerebro/scripts/sweep-epics.sh"

# ISO-8601 UTC to a Unix timestamp, GNU or BSD date.
iso_to_epoch() {
date -u -d "$1" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$1" +%s
}

# --- refuses a call without --json ---------------------------------------------------------------
set +e
(cd "$consumer" && "$sweep") >/dev/null 2>&1
status=$?
set -e
[ "$status" -eq 2 ] || fail "a call without --json: expected exit 2, got $status"
pass "refuses a call without --json"

# --- no eligible epic is an empty array ----------------------------------------------------------
echo '[]' > "$epics_file"
echo '[]' > "$children_file"
out="$(cd "$consumer" && "$sweep" --json)" || fail "sweep-epics.sh --json exited non-zero"
[ "$out" = "[]" ] || fail "no eligible epic printed '$out'"
pass "prints an empty array when no epic is eligible"

# --- the shared table: the last child is a real child, never an event ----------------------------
table="$repo_root/tests/lib/epic-family.json"
rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table"
[ -n "$rows" ] || fail "$table has no rows"
bad=""
while IFS= read -r row; do
name="$(jq -r '.name' <<<"$row")"
epic_id="$(jq -r '.epic.id' <<<"$row")"
last="$(jq -r '.last_child_closed_at // empty' <<<"$row")"
jq -c '[{epic: {id: .epic.id, title: .name}}]' <<<"$row" > "$epics_file"
jq -c '.under' <<<"$row" > "$children_file"
if ! out="$(cd "$consumer" && "$sweep" --json)"; then
bad="$bad
$name: sweep-epics.sh exited non-zero"
continue
fi
got="$(jq -r --arg id "$epic_id" '.[] | select(.id == $id) | .minutes_since_last_child_closed' <<<"$out")"
if [ -z "$last" ]; then
if [ "$got" != null ]; then
bad="$bad
$name: minutes is '$got', the table has no last child close"
fi
else
# The script reads the clock a moment before this does, so a minute boundary may fall between.
want=$(( ($(date -u +%s) - $(iso_to_epoch "$last")) / 60 ))
if ! [[ "$got" =~ ^[0-9]+$ ]] || [ $(( want - got )) -lt 0 ] || [ $(( want - got )) -gt 1 ]; then
bad="$bad
$name: minutes is '$got', since $last is $want"
fi
fi
done <<<"$rows"
[ -z "$bad" ] || fail "sweep-epics.sh disagrees with tests/lib/epic-family.json:$bad"
pass "answers every row of the epic-family table"

suite_passed
31 changes: 31 additions & 0 deletions tests/verifier-epic-candidates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,35 @@ if grep -qxF "ep-only-events" <<<"$ids"; then bad+=" epic whose only children ar
[ -z "$bad" ] || fail "event children counted as members:$bad"
pass "ignores event beads when judging a family"

# --- the shared table: which beads under an epic are its family (cb-7iv1) ----------------------
#
# tests/lib/epic-family.json is the rule every implementation answers. Its fixtures carry no
# verification labels, so every family is unverified and an epic is due exactly when it has a
# family and every member is closed: `family_closed`.
table="$repo_root/tests/lib/epic-family.json"
rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table"
[ -n "$rows" ] || fail "$table has no rows"
bad=""
while IFS= read -r row; do
name="$(jq -r '.name' <<<"$row")"
epic_id="$(jq -r '.epic.id' <<<"$row")"
want="$(jq -r '.family_closed' <<<"$row")"
# `--exclude-type event` on the real `bd list` keeps events out of it, so the list is the epic.
set_stdout list "$(jq -c '[.epic + {issue_type: "epic", labels: []}]' <<<"$row")"
set_stdout "children.$epic_id" "$(jq -c '[.under[] + {labels: []}]' <<<"$row")"
if ! ids="$(run)"; then
bad="$bad
$name: verifier-epic-candidates failed"
continue
fi
got=false
if grep -qxF "$epic_id" <<<"$ids"; then got=true; fi
if [ "$got" != "$want" ]; then
bad="$bad
$name: listed is $got, family_closed is $want"
fi
done <<<"$rows"
[ -z "$bad" ] || fail "verifier-epic-candidates disagrees with tests/lib/epic-family.json:$bad"
pass "answers every row of the epic-family table"

suite_passed
47 changes: 34 additions & 13 deletions tests/work-beads.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,22 +173,43 @@ ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')"
[ "$ids" = "tt-ord " ] || fail "a parented epic survived: got '$ids'"
pass "keeps a childless epic and drops a parented one, asking bd children per epic"

# --- an epic's event beads are not its children (cb-cng9) ---------------------------------------
# --- an epic's event beads are not its children: the shared table (cb-cng9, cb-7iv1) -----------
#
# `bd set-state` files an `event` bead under the bead it changed, and `bd children` returns it. An
# epic nobody split but whose state changed once (cb-dr8, event child cb-dr8.6) counted one child
# and left the board. Only the fallback is exposed: the list the prefilter reads excludes events.
set_stub '[{"id":"tt-lone","issue_type":"epic"},{"id":"tt-ord","issue_type":"task"}]'
set_stub_for children '[{"id":"tt-lone.1","issue_type":"event","status":"closed"}]'
ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')"
[ "$ids" = "tt-lone tt-ord " ] || fail "an epic whose only children are events was classed parented: got '$ids'"
pass "keeps an epic whose only children are event beads"

set_stub '[{"id":"tt-lone","issue_type":"epic"},{"id":"tt-ord","issue_type":"task"}]'
set_stub_for children '[{"id":"tt-lone.1","issue_type":"event"},{"id":"tt-lone.2","issue_type":"task"}]'
ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')"
[ "$ids" = "tt-ord " ] || fail "an epic with a real child beside an event survived: got '$ids'"
pass "drops an epic with a real child beside an event bead"
# and left the board. tests/lib/epic-family.json is the rule every implementation answers; here the
# epic stays on the list exactly while its `family` is empty. Each row runs twice: once with the
# epic alone in the list (the `bd children` fallback decides) and once with everything under it in
# the list too (the id-prefix prefilter decides, after the jq guard has dropped the events).
table="$repo_root/tests/lib/epic-family.json"
rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table"
[ -n "$rows" ] || fail "$table has no rows"
bad=""
while IFS= read -r row; do
name="$(jq -r '.name' <<<"$row")"
epic_id="$(jq -r '.epic.id' <<<"$row")"
childless="$(jq -r '.family | length == 0' <<<"$row")"
under="$(jq -c '.under' <<<"$row")"
for listing in alone with-under; do
if [ "$listing" = alone ]; then
set_stub "$(jq -c '[.epic + {issue_type: "epic"}]' <<<"$row")"
else
set_stub "$(jq -c '[.epic + {issue_type: "epic"}] + .under' <<<"$row")"
fi
set_stub_for children "$under"
if ! listed="$(run --status open,closed | jq --arg id "$epic_id" 'any(.[]; .id == $id)')"; then
bad="$bad
$name ($listing): work-beads failed"
continue
fi
if [ "$listed" != "$childless" ]; then
bad="$bad
$name ($listing): epic on the list is $listed, a childless epic is $childless"
fi
done
done <<<"$rows"
[ -z "$bad" ] || fail "work-beads disagrees with tests/lib/epic-family.json:$bad"
pass "answers every row of the epic-family table, through bd children and through the prefilter"

# --- the prefilter: a child in the list settles its parent with no bd call (cb-hzl) -------------
#
Expand Down
Loading