From 0f30fe41a43b3d95b7b00f066cdbdca6e2219013 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 09:56:14 -0700 Subject: [PATCH 1/2] Implement bead lifecycle CLOSING structure Wire the --beads flag end to end (it was documented but never implemented: no arg parsing, no beads_id= meta write, fm-bead-stamp.sh was dead code). fm-spawn.sh now records beads_id= in task meta and invokes fm-bead-stamp.sh after launch. fm-teardown.sh closes a task's linked bead automatically once its work is confirmed landed, and skips closing on a --force or refused teardown. The close call is fail-open: a missing task CLI or a rejected close warns and never blocks teardown. Add bin/fm-ledger.sh as the safety net for beads that fall outside that automatic close: claimed, unclosed beads that have gone quiet past --stale-days are surfaced as likely_dropped and can be closed individually or in bulk. Clarify fm-brief-hooks.d/beads.sh's Bead Closure section so a crewmate that cannot reach its own close step knows firstmate's teardown will close the bead for it. Cross-reference beads_id= in AGENTS.md's state/.meta field list. --- AGENTS.md | 2 +- bin/fm-brief-hooks.d/beads.sh | 1 + bin/fm-ledger.sh | 137 ++++++++++++++++++ bin/fm-spawn.sh | 29 +++- bin/fm-teardown.sh | 25 ++++ tests/fm-ledger.test.sh | 252 ++++++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 84 +++++++++++- 7 files changed, 519 insertions(+), 11 deletions(-) create mode 100755 bin/fm-ledger.sh create mode 100755 tests/fm-ledger.test.sh diff --git a/AGENTS.md b/AGENTS.md index 378cda3dad..59b7f4e512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .parlay-listen-pid background `parlay listen` pid from a best-effort Parlay chat-panel enrollment at spawn (bin/fm-spawn.sh header); killed and removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; optional beads_id= is recorded only when --beads was passed at spawn, driving the claim-first/close-last bead lifecycle (bin/fm-brief-hooks.d/beads.sh, bin/fm-teardown.sh header, bin/fm-ledger.sh) under section 7's bead-linked dispatch; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check diff --git a/bin/fm-brief-hooks.d/beads.sh b/bin/fm-brief-hooks.d/beads.sh index ab13645df3..6b7bc9d8bf 100755 --- a/bin/fm-brief-hooks.d/beads.sh +++ b/bin/fm-brief-hooks.d/beads.sh @@ -19,4 +19,5 @@ task set-state $FM_HOOK_BEADS_ID lifecycle=claimed --reason 'brief read and acce # Bead Closure Before appending \`done:\` to the status file, close this bead: \`task close $FM_HOOK_BEADS_ID\`. That closure is what a registered watcher check uses to trigger your cleanup - do this as the last step before reporting done. +If you cannot reach this step, do not worry about it further: firstmate closes this bead automatically once your work is confirmed landed and this task is torn down. SECTION diff --git a/bin/fm-ledger.sh b/bin/fm-ledger.sh new file mode 100755 index 0000000000..c30b727f1c --- /dev/null +++ b/bin/fm-ledger.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# fm-ledger.sh - surface beads whose linked work looks landed but were never +# closed, so they can be reviewed and closed instead of leaking open forever. +# +# A crewmate claims its linked bead on receipt (dispatch=claimed / lifecycle=claimed, +# see bin/fm-brief-hooks.d/beads.sh) and fm-teardown.sh now closes it automatically +# once that task's landed work is torn down (see fm-teardown.sh's header). This +# command is the safety net for everything that falls outside that automatic close - +# a --force teardown, a task that never reached teardown, or a bead claimed outside +# firstmate's own dispatch. A bead is "likely_dropped" when it is claimed, still +# open, and has gone quiet longer than --stale-days: whoever claimed it is very +# unlikely to still be working it. +# +# fm-teardown.sh deletes a task's local meta as its very last step, so this command +# cannot rely on local firstmate state to find a leaked bead - it derives +# likely_dropped purely from the beads store itself (labels, status, timestamps). +# +# Usage: +# fm-ledger.sh [--json] [--stale-days ] list likely_dropped beads (default: 2 days) +# fm-ledger.sh --close [...] close specific bead ids +# fm-ledger.sh --close-all [--stale-days ] [--yes] +# close every currently-listed likely_dropped +# bead; without --yes, only lists what would close +# +# Env: +# FM_BEADS_BIN beads CLI to invoke (default: task) +set -u + +BIN="${FM_BEADS_BIN:-task}" +STALE_DAYS=2 +MODE=list +YES=0 +CLOSE_IDS=() + +usage() { + # The whole leading comment block, ending at the first line that is not a + # comment. Derived rather than a fixed line range, which would silently + # truncate this help mid-sentence every time the header above grew. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --json) MODE=json; shift ;; + --stale-days) + [ $# -ge 2 ] || { echo "fm-ledger: --stale-days requires a value" >&2; exit 2; } + STALE_DAYS=$2; shift 2 ;; + --stale-days=*) STALE_DAYS=${1#--stale-days=}; shift ;; + --close) + MODE=close; shift + while [ $# -gt 0 ] && [ "${1#--}" = "$1" ]; do + CLOSE_IDS+=("$1"); shift + done + ;; + --close-all) MODE=close-all; shift ;; + --yes) YES=1; shift ;; + *) echo "fm-ledger: unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$STALE_DAYS" in + ''|*[!0-9]*) echo "fm-ledger: --stale-days must be a non-negative integer, got: $STALE_DAYS" >&2; exit 2 ;; +esac + +command -v "$BIN" >/dev/null 2>&1 || { echo "fm-ledger: $BIN CLI not found" >&2; exit 1; } + +likely_dropped_query() { + printf 'label=lifecycle:claimed AND status!=closed AND updated<%sd' "$STALE_DAYS" +} + +fetch_likely_dropped() { + "$BIN" query "$(likely_dropped_query)" --json --limit 0 2>/dev/null || echo '[]' +} + +close_one() { + local id=$1 + if "$BIN" close "$id" --reason "closed via fm-ledger: work landed, bead was never closed" >/dev/null 2>&1; then + echo "closed $id" + else + echo "warning: could not close $id" >&2 + return 1 + fi +} + +case "$MODE" in + close) + [ "${#CLOSE_IDS[@]}" -gt 0 ] || { echo "fm-ledger: --close requires at least one bead id" >&2; exit 2; } + rc=0 + for id in "${CLOSE_IDS[@]}"; do + close_one "$id" || rc=1 + done + exit "$rc" + ;; + close-all) + command -v jq >/dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; } + beads=$(fetch_likely_dropped) + ids=$(printf '%s' "$beads" | jq -r '.[].id') + if [ -z "$ids" ]; then + echo "no likely-dropped beads to close" + exit 0 + fi + if [ "$YES" != 1 ]; then + echo "would close:" + printf '%s\n' "$ids" + echo "re-run with --yes to close these beads" + exit 0 + fi + rc=0 + while IFS= read -r id; do + [ -n "$id" ] || continue + close_one "$id" || rc=1 + done </dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; } + beads=$(fetch_likely_dropped) + printf '%s' "$beads" | jq '[.[] | {id, title, status, updated_at, likely_dropped: true}]' + ;; + list) + command -v jq >/dev/null 2>&1 || { echo "fm-ledger: jq not found" >&2; exit 1; } + beads=$(fetch_likely_dropped) + count=$(printf '%s' "$beads" | jq 'length') + if [ "$count" -eq 0 ]; then + echo "No likely-dropped beads (claimed, unclosed, idle > ${STALE_DAYS}d)." + exit 0 + fi + echo "Likely-dropped beads (claimed, unclosed, idle > ${STALE_DAYS}d):" + echo + printf '%s' "$beads" | jq -r '.[] | " \(.id) \(.updated_at) \(.title)"' + echo + echo "close with: fm-ledger.sh --close [...], or fm-ledger.sh --close-all --yes" + ;; +esac diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 825bc74fd6..e56d27b892 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a # secondmate in its isolated firstmate home. -# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] [--scout] -# fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] --secondmate +# Usage: fm-spawn.sh [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] [--beads ] [--scout] +# fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--label ] [--beads ] --secondmate # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. # --model and --effort are concrete profile @@ -95,11 +95,12 @@ # --scout records kind=scout in the task's meta (report deliverable, scratch worktree; # see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a # provisioned firstmate home; the default is kind=ship. -# --beads links this task to an external bead item for lifecycle tracking: the -# dispatch=sent and lifecycle=sent state dimensions are stamped via fm-bead-stamp.sh -# after spawn, and the brief includes Bead Receipt/Closure sections (when FM_HOOK_BEADS_ID -# is set) asking the worker to confirm dispatch=claimed/lifecycle=claimed and close the -# bead on completion. +# --beads links this task to an external bead item for lifecycle tracking: the id is +# recorded as beads_id= in the task's meta (fm-teardown.sh reads it to close the bead once +# this task's work is confirmed landed), the dispatch=sent and lifecycle=sent state +# dimensions are stamped via fm-bead-stamp.sh after spawn, and the brief includes Bead +# Receipt/Closure sections (when FM_HOOK_BEADS_ID is set) asking the worker to confirm +# dispatch=claimed/lifecycle=claimed and close the bead on completion. # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real @@ -202,11 +203,13 @@ MODEL= EFFORT= BACKEND_ARG= LABEL_ARG= +BEADS_ARG= HARNESS_SET=0 MODEL_SET=0 EFFORT_SET=0 BACKEND_SET=0 LABEL_SET=0 +BEADS_SET=0 POS=() want_value= for a in "$@"; do @@ -220,6 +223,7 @@ for a in "$@"; do effort) EFFORT=$a; EFFORT_SET=1 ;; backend) BACKEND_ARG=$a; BACKEND_SET=1 ;; label) LABEL_ARG=$a; LABEL_SET=1 ;; + beads) BEADS_ARG=$a; BEADS_SET=1 ;; *) echo "error: internal parser state for --$want_value" >&2; exit 1 ;; esac want_value= @@ -238,6 +242,8 @@ for a in "$@"; do --backend=*) BACKEND_ARG=${a#--backend=}; BACKEND_SET=1 ;; --label) want_value=label ;; --label=*) LABEL_ARG=${a#--label=}; LABEL_SET=1 ;; + --beads) want_value=beads ;; + --beads=*) BEADS_ARG=${a#--beads=}; BEADS_SET=1 ;; *) POS+=("$a") ;; esac done @@ -247,6 +253,7 @@ done [ "$EFFORT_SET" -eq 0 ] || [ -n "$EFFORT" ] || { echo "error: --effort requires a non-empty value" >&2; exit 1; } [ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; } [ "$LABEL_SET" -eq 0 ] || [ -n "$LABEL_ARG" ] || { echo "error: --label requires a non-empty value" >&2; exit 1; } +[ "$BEADS_SET" -eq 0 ] || [ -n "$BEADS_ARG" ] || { echo "error: --beads requires a non-empty value" >&2; exit 1; } case "$EFFORT" in ''|low|medium|high|xhigh|max) ;; *) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;; @@ -1644,6 +1651,7 @@ META_WINDOW=$T echo "effort=${EFFORT:-default}" [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" [ -z "$LABEL_ARG" ] || echo "label=$LABEL_ARG" + [ -z "$BEADS_ARG" ] || echo "beads_id=$BEADS_ARG" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; # data/fm-backend-design-d7's P1 compatibility contract). @@ -1760,4 +1768,11 @@ if command -v parlay >/dev/null 2>&1; then || echo "warning: could not record parlay listen pid for $ID (non-blocking)" >&2 fi +# Best-effort bead dispatch stamp. fm-bead-stamp.sh is fail-open by design (a +# missing task CLI or unreachable bead warns on stderr and exits 0), so this +# never blocks or fails an already-confirmed spawn. +if [ -n "$BEADS_ARG" ]; then + "$FM_ROOT/bin/fm-bead-stamp.sh" "$BEADS_ARG" "$ID" || true +fi + echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 82fb33b6fc..dfcf4afab3 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -56,6 +56,13 @@ # panel (see bin/fm-spawn.sh's header for the enrollment contract): the recorded # `parlay listen` background pid is always killed, and `parlay agent-down` is # called only when `parlay` is on PATH. Neither ever blocks or fails teardown. +# A task linked to a bead (beads_id= in meta, set by fm-spawn.sh --beads) has that +# bead closed automatically once teardown reaches this point without --force, i.e. +# every landed-work gate above already passed. --force never closes the linked bead, +# since --force tears down without confirming the work landed. Closing is fail-open +# like fm-bead-stamp.sh: a missing task CLI or a close the CLI rejects warns on +# stderr and never blocks or fails an already-confirmed teardown. bin/fm-ledger.sh +# is the safety net for a bead that was claimed but never reaches this path. # Usage: fm-teardown.sh [--force] # --force skips ordinary-task dirty and landed-work checks, skips scout report # checks, and discards secondmate child work for kind=secondmate. Only use it @@ -152,6 +159,7 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +BEADS_ID=$(fm_meta_get "$META" beads_id) PUBLIC_FOLLOWUP_HOME=$FM_HOME PUBLIC_FOLLOWUP_STATE=$STATE PUBLIC_FOLLOWUP_WORK_HOME=main @@ -312,6 +320,22 @@ deregister_parlay_agent() { fi } +# Close the bead linked to this task (beads_id= in meta, set by fm-spawn.sh --beads) +# once its work is confirmed landed. Only called for a non-force teardown that +# reached this point, i.e. every REFUSED landed-work gate above already passed. +# Fail-open by design, matching fm-bead-stamp.sh: a missing task CLI or a close +# the CLI rejects (already closed, unreachable store) warns on stderr and never +# blocks or fails an already-confirmed teardown. +close_linked_bead() { + local beads_id=$1 id=$2 + command -v task >/dev/null 2>&1 || { + echo "warning: task CLI not found on PATH, could not close bead $beads_id for $id" >&2 + return 0 + } + task close "$beads_id" --reason "landed: firstmate task $id teardown confirmed work landed" >/dev/null 2>&1 \ + || echo "warning: could not close bead $beads_id for $id (already closed or unreachable)" >&2 +} + validate_pr_poll_cleanup() { local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0 fm_task_id_path_safe "$id" || return 0 @@ -1379,6 +1403,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 +[ -z "$BEADS_ID" ] || [ "$FORCE" = "--force" ] || close_linked_bead "$BEADS_ID" "$ID" rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" diff --git a/tests/fm-ledger.test.sh b/tests/fm-ledger.test.sh new file mode 100755 index 0000000000..f33c660573 --- /dev/null +++ b/tests/fm-ledger.test.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-ledger.sh: surfacing and closing likely_dropped +# beads (claimed, unclosed, gone quiet longer than --stale-days). +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LEDGER="$ROOT/bin/fm-ledger.sh" +TMP_ROOT=$(fm_test_tmproot fm-ledger) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +# A fake `task` (beads CLI). `query` filters a fixed three-bead fixture by the +# --stale-days threshold embedded in the query expression's `updated` logs to +# FM_TEST_LEDGER_CALLS_LOG and succeeds, except for id "bead-fail" which always +# fails, so callers can assert on the warn-and-continue path. +make_task_mock() { # + local fb + fb=$(fm_fakebin "$1") + cat > "$fb/task" <<'SH' +#!/usr/bin/env bash +set -u +FIXTURE='[ + {"id":"bead-a","title":"Old dropped work","status":"open","updated_at":"2026-07-01T00:00:00Z","days_idle":10}, + {"id":"bead-c","title":"Medium idle claim","status":"open","updated_at":"2026-07-27T00:00:00Z","days_idle":5}, + {"id":"bead-b","title":"Fresh claim","status":"open","updated_at":"2026-07-30T00:00:00Z","days_idle":1} +]' +case "${1:-}" in + query) + expr=$2 + n=$(printf '%s' "$expr" | sed -n -E 's/.*updated<([0-9]+)d.*/\1/p') + [ -n "$n" ] || n=0 + printf '%s' "$FIXTURE" | jq --argjson n "$n" '[.[] | select(.days_idle >= $n)]' + ;; + close) + id=$2 + if [ "$id" = bead-fail ]; then + echo "mock: refusing to close $id" >&2 + exit 1 + fi + printf '%s\n' "close $id" >> "${FM_TEST_LEDGER_CALLS_LOG:?FM_TEST_LEDGER_CALLS_LOG not set}" + exit 0 + ;; + *) exit 0 ;; +esac +SH + chmod +x "$fb/task" + printf '%s\n' "$fb" +} + +run_ledger() { # [args...] + "$LEDGER" "$@" +} + +test_ledger_lists_likely_dropped_default_threshold() { + local case_dir fb out rc + case_dir="$TMP_ROOT/list-default" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + out=$(PATH="$fb:$PATH" run_ledger) + rc=$? + set -e + + expect_code 0 "$rc" "list-default: fm-ledger.sh should succeed" + assert_contains "$out" "bead-a" "list-default: missing stale bead-a (idle 10d)" + assert_contains "$out" "bead-c" "list-default: missing stale bead-c (idle 5d)" + assert_not_contains "$out" "bead-b" "list-default: bead-b (idle 1d) should not be listed under the 2d default" + assert_contains "$out" "--close" "list-default: missing the close hint" + pass "fm-ledger.sh lists claimed, unclosed, idle beads under the default 2-day threshold" +} + +test_ledger_json_mode_shapes_output() { + local case_dir fb out rc ids + case_dir="$TMP_ROOT/json-mode" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + out=$(PATH="$fb:$PATH" run_ledger --json) + rc=$? + set -e + + expect_code 0 "$rc" "json-mode: fm-ledger.sh --json should succeed" + ids=$(printf '%s' "$out" | jq -r '.[].id' | sort | tr '\n' ' ') + [ "$ids" = "bead-a bead-c " ] || fail "json-mode: expected ids 'bead-a bead-c', got '$ids'" + [ "$(printf '%s' "$out" | jq '[.[] | .likely_dropped] | all')" = true ] \ + || fail "json-mode: every entry should have likely_dropped: true" + pass "fm-ledger.sh --json emits only the likely_dropped beads, each flagged" +} + +test_ledger_stale_days_threshold_changes_results() { + local case_dir fb out_zero out_high rc + case_dir="$TMP_ROOT/stale-days" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + out_zero=$(PATH="$fb:$PATH" run_ledger --stale-days 0) + rc=$? + set -e + expect_code 0 "$rc" "stale-days=0: fm-ledger.sh should succeed" + assert_contains "$out_zero" "bead-b" "stale-days=0: bead-b should appear once the threshold drops to 0" + + set +e + out_high=$(PATH="$fb:$PATH" run_ledger --stale-days 6) + rc=$? + set -e + expect_code 0 "$rc" "stale-days=6: fm-ledger.sh should succeed" + assert_contains "$out_high" "bead-a" "stale-days=6: bead-a (idle 10d) should still appear" + assert_not_contains "$out_high" "bead-c" "stale-days=6: bead-c (idle 5d) should drop out above the threshold" + pass "fm-ledger.sh --stale-days changes which beads are surfaced" +} + +test_ledger_close_specific_ids() { + local case_dir fb out rc log + case_dir="$TMP_ROOT/close-specific" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + log="$case_dir/calls.log" + + set +e + out=$(FM_TEST_LEDGER_CALLS_LOG="$log" PATH="$fb:$PATH" run_ledger --close bead-a bead-c) + rc=$? + set -e + + expect_code 0 "$rc" "close-specific: closing known ids should succeed" + assert_contains "$out" "closed bead-a" "close-specific: missing confirmation for bead-a" + assert_contains "$out" "closed bead-c" "close-specific: missing confirmation for bead-c" + assert_grep "close bead-a" "$log" "close-specific: task close was not called for bead-a" + assert_grep "close bead-c" "$log" "close-specific: task close was not called for bead-c" + pass "fm-ledger.sh --close closes exactly the given bead ids" +} + +test_ledger_close_requires_at_least_one_id() { + local case_dir fb err rc + case_dir="$TMP_ROOT/close-no-ids" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + err=$(PATH="$fb:$PATH" run_ledger --close 2>&1 >/dev/null) + rc=$? + set -e + + expect_code 2 "$rc" "close-no-ids: --close with no ids should error" + assert_contains "$err" "requires at least one bead id" "close-no-ids: missing the expected error text" + pass "fm-ledger.sh --close with no ids errors instead of closing nothing silently" +} + +test_ledger_close_all_without_yes_previews_only() { + local case_dir fb out rc log + case_dir="$TMP_ROOT/close-all-preview" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + log="$case_dir/calls.log" + + set +e + out=$(FM_TEST_LEDGER_CALLS_LOG="$log" PATH="$fb:$PATH" run_ledger --close-all) + rc=$? + set -e + + expect_code 0 "$rc" "close-all-preview: preview mode should succeed" + assert_contains "$out" "would close" "close-all-preview: missing the preview label" + assert_contains "$out" "bead-a" "close-all-preview: missing bead-a in the preview" + assert_contains "$out" "--yes" "close-all-preview: missing the --yes hint" + assert_absent "$log" "close-all-preview: task close should not run without --yes" + pass "fm-ledger.sh --close-all without --yes only previews what would close" +} + +test_ledger_close_all_with_yes_closes_everything_listed() { + local case_dir fb out rc log + case_dir="$TMP_ROOT/close-all-yes" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + log="$case_dir/calls.log" + + set +e + out=$(FM_TEST_LEDGER_CALLS_LOG="$log" PATH="$fb:$PATH" run_ledger --close-all --yes) + rc=$? + set -e + + expect_code 0 "$rc" "close-all-yes: --close-all --yes should succeed" + assert_contains "$out" "closed bead-a" "close-all-yes: bead-a was not reported closed" + assert_contains "$out" "closed bead-c" "close-all-yes: bead-c was not reported closed" + assert_grep "close bead-a" "$log" "close-all-yes: task close was not called for bead-a" + assert_grep "close bead-c" "$log" "close-all-yes: task close was not called for bead-c" + pass "fm-ledger.sh --close-all --yes closes every currently-listed likely_dropped bead" +} + +test_ledger_close_all_none_found() { + local case_dir fb out rc + case_dir="$TMP_ROOT/close-all-none" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + out=$(PATH="$fb:$PATH" run_ledger --close-all --yes --stale-days 999) + rc=$? + set -e + + expect_code 0 "$rc" "close-all-none: should succeed with nothing to close" + assert_contains "$out" "no likely-dropped beads to close" "close-all-none: missing the empty-set message" + pass "fm-ledger.sh --close-all reports cleanly when nothing is likely_dropped" +} + +test_ledger_close_failure_warns_and_exits_nonzero() { + local case_dir fb err rc + case_dir="$TMP_ROOT/close-failure" + mkdir -p "$case_dir" + fb=$(make_task_mock "$case_dir") + + set +e + err=$(FM_TEST_LEDGER_CALLS_LOG="$case_dir/calls.log" PATH="$fb:$PATH" run_ledger --close bead-fail 2>&1 >/dev/null) + rc=$? + set -e + + expect_code 1 "$rc" "close-failure: a rejected close should exit nonzero" + assert_contains "$err" "could not close bead-fail" "close-failure: missing the warning for the failed close" + pass "fm-ledger.sh --close warns and exits nonzero when the CLI rejects a close" +} + +test_ledger_missing_task_cli_errors() { + local case_dir path_without_task err rc + case_dir="$TMP_ROOT/missing-cli" + mkdir -p "$case_dir" + path_without_task=$(fm_path_without task) + + set +e + err=$(PATH="$path_without_task" run_ledger 2>&1 >/dev/null) + rc=$? + set -e + + expect_code 1 "$rc" "missing-cli: fm-ledger.sh should error without the task CLI" + assert_contains "$err" "task CLI not found" "missing-cli: missing the expected error text" + pass "fm-ledger.sh errors clearly when the task CLI is not on PATH" +} + +test_ledger_lists_likely_dropped_default_threshold +test_ledger_json_mode_shapes_output +test_ledger_stale_days_threshold_changes_results +test_ledger_close_specific_ids +test_ledger_close_requires_at_least_one_id +test_ledger_close_all_without_yes_previews_only +test_ledger_close_all_with_yes_closes_everything_listed +test_ledger_close_all_none_found +test_ledger_close_failure_warns_and_exits_nonzero +test_ledger_missing_task_cli_errors diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 2c8c8a772c..e3d647b74d 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -151,16 +151,19 @@ SH chmod +x "$case_dir/fakebin/tasks-axi" } -# Write a meta file for the task. Args: case_dir mode kind +# Write a meta file for the task. Args: case_dir mode kind [beads_id] write_meta() { - local case_dir=$1 mode=$2 kind=$3 + local case_dir=$1 mode=$2 kind=$3 beads_id=${4:-} + local extra=() + [ -z "$beads_id" ] || extra+=("beads_id=$beads_id") fm_write_meta "$case_dir/state/task-x1.meta" \ "window=firstmate:fm-task-x1" \ "endpoint_task_id=task-x1" \ "worktree=$case_dir/wt" \ "project=$case_dir/project" \ "kind=$kind" \ - "mode=$mode" + "mode=$mode" \ + "${extra[@]+"${extra[@]}"}" } # Commit something on the worktree's task branch. Args: case_dir [message] @@ -170,6 +173,18 @@ wt_commit() { commit -q --allow-empty -m "$msg" } +# Mock `task` (the beads CLI): logs every invocation to $case_dir/task-calls.log +# and succeeds. Args: case_dir +add_beads_task_mock() { + local case_dir=$1 + cat > "$case_dir/fakebin/task" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_TEST_TASK_CALLS_LOG" +exit 0 +SH + chmod +x "$case_dir/fakebin/task" +} + # Add a fork bare repo and register it as a remote on the project, then push # the worktree's task branch to it and fetch into the project so the worktree # sees the remote-tracking ref. Args: case_dir @@ -1490,6 +1505,66 @@ test_teardown_kills_pid_even_when_parlay_absent() { pass "a clean teardown still kills the recorded parlay-listen pid even when parlay is absent from PATH" } +test_beads_linked_task_closes_bead_on_landed_teardown() { + local case_dir rc + case_dir=$(make_case beads-close-on-land) + write_meta "$case_dir" local-only ship bead-close-1 + add_beads_task_mock "$case_dir" + wt_commit "$case_dir" "fix the thing" + add_fork_with_pushed_branch "$case_dir" + + set +e + FM_TEST_TASK_CALLS_LOG="$case_dir/task-calls.log" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "beads-close-on-land: teardown should succeed when HEAD is on a fork remote" + grep -q '^close bead-close-1 ' "$case_dir/task-calls.log" 2>/dev/null \ + || fail "beads-close-on-land: linked bead was not closed on landed teardown: $(cat "$case_dir/task-calls.log" 2>/dev/null)" + pass "a beads-linked task's bead is closed automatically on a landed (non-force) teardown" +} + +test_beads_linked_task_does_not_close_bead_on_force_teardown() { + local case_dir rc + case_dir=$(make_case beads-no-close-force) + write_meta "$case_dir" local-only ship bead-close-2 + add_beads_task_mock "$case_dir" + wt_commit "$case_dir" "unpushed work" + + set +e + FM_TEST_TASK_CALLS_LOG="$case_dir/task-calls.log" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "beads-no-close-force: --force should bypass the unpushed-work check" + ! grep -q '^close ' "$case_dir/task-calls.log" 2>/dev/null \ + || fail "beads-no-close-force: linked bead was closed despite --force (work not confirmed landed)" + pass "a beads-linked task's bead is NOT closed on a --force teardown" +} + +test_beads_linked_task_does_not_close_bead_on_refused_teardown() { + local case_dir rc + case_dir=$(make_case beads-no-close-refused) + write_meta "$case_dir" local-only ship bead-close-3 + add_beads_task_mock "$case_dir" + wt_commit "$case_dir" "unpushed work" + + set +e + FM_TEST_TASK_CALLS_LOG="$case_dir/task-calls.log" \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "beads-no-close-refused: teardown should refuse truly unpushed work" + grep -q REFUSED "$case_dir/stderr" \ + || fail "beads-no-close-refused: teardown did not print REFUSED" + ! grep -q '^close ' "$case_dir/task-calls.log" 2>/dev/null \ + || fail "beads-no-close-refused: linked bead was closed despite a refused teardown" + pass "a beads-linked task's bead is NOT closed when teardown refuses unlanded work" +} + test_local_only_fork_remote_allows test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present @@ -1526,3 +1601,6 @@ test_empty_retry_wait_uses_default_without_aborting test_fractional_legacy_retry_wait_refuses_without_arithmetic_error test_teardown_deregisters_parlay_when_present test_teardown_kills_pid_even_when_parlay_absent +test_beads_linked_task_closes_bead_on_landed_teardown +test_beads_linked_task_does_not_close_bead_on_force_teardown +test_beads_linked_task_does_not_close_bead_on_refused_teardown From ae8d7ef9ce075d930ba17a030e54bd52bb521736 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 10:40:04 -0700 Subject: [PATCH 2/2] no-mistakes(document): Doc'd bead auto-close on teardown and fm-ledger.sh in AGENTS.md and docs/scripts.md --- AGENTS.md | 2 + docs/scripts.md | 1 + tests/fm-spawn-beads.test.sh | 132 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100755 tests/fm-spawn-beads.test.sh diff --git a/AGENTS.md b/AGENTS.md index 59b7f4e512..b0a8b88abc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,6 +268,8 @@ Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in sec The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task. After spawning, confirm the worker is processing the brief, handle any trust dialog through `harness-adapters`, and record ship or scout work as under way. When spawning with `--beads `, the task is linked to an external bead item for progress tracking on `mg` or similar tools; `fm-bead-stamp.sh` stamps the bead's `dispatch=sent` and `lifecycle=sent` state dimensions, and the generated brief includes instructions for the worker to confirm `dispatch=claimed` and `lifecycle=claimed` after reading, and to close the bead on completion. +`fm-teardown.sh` also closes the linked bead itself once a non-force teardown confirms the task's work landed, so a worker that cannot reach the closing step is still covered; `--force` and a refused teardown never close it. +`bin/fm-ledger.sh` is the fleet-wide safety net for a bead that falls outside both paths - claimed, still open, and gone quiet past its staleness window - and can list or close those likely-dropped beads. A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. Steer a worker with short single-line messages through fail-closed `fm-send`; put long instructions in a file. diff --git a/docs/scripts.md b/docs/scripts.md index cb07cb274d..5b85ad204a 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -20,6 +20,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-decision-hold.sh` | Create, verify, complete, and resolve durable captain-held decisions | | `fm-brief.sh` | Scaffold ship, scout, secondmate-charter, and Herdr-lab briefs; load hooks when FM_HOOK_BEADS_ID or other hook env vars are set | | `fm-bead-stamp.sh` | Stamp a linked bead's dispatch and lifecycle state when a task is spawned with --beads | +| `fm-ledger.sh` | List or close likely-dropped beads: claimed, still open, and idle past a staleness window | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | diff --git a/tests/fm-spawn-beads.test.sh b/tests/fm-spawn-beads.test.sh new file mode 100755 index 0000000000..61776ecb19 --- /dev/null +++ b/tests/fm-spawn-beads.test.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Behavior tests for fm-spawn.sh's --beads wiring: the id is recorded as +# beads_id= in the task's meta, and fm-bead-stamp.sh is invoked with it after +# a confirmed spawn (see bin/fm-spawn.sh's header and its --beads section). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-beads) + +# make_spawn_fakebin : a fake tmux that always reports FM_FAKE_PANE_PATH +# for pane_current_path (an already-settled pane, no staleness), plus exit-0 +# treehouse. Mirrors fm-spawn-parlay.test.sh's already-settled case. +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +# make_spawn_case : a home + primary project with a real worktree +# (the pane's reported cwd) and a brief, ready for a successful spawn. +make_spawn_case() { + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf 'codex\n' > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +read_spawn_record() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR <&1 +} + +# Mock `task` (the beads CLI) as fm-bead-stamp.sh calls it: logs every +# invocation and always succeeds. +add_beads_task_mock() { + local fakebin_dir=$1 calls_log=$2 + cat > "$fakebin_dir/task" <> "$calls_log" +exit 0 +SH + chmod +x "$fakebin_dir/task" +} + +test_spawn_beads_records_meta_and_stamps_bead() { + local rec id out calls_log + id=spawn-beads-present-z1 + rec=$(make_spawn_case spawn-beads-present "$id") + read_spawn_record "$rec" + calls_log="$CASE_DIR/task-calls.log" + add_beads_task_mock "$FAKEBIN_DIR" "$calls_log" + + out=$(run_case_spawn "$id" --beads bead-42) + expect_code 0 "$?" "spawn should succeed with --beads set" + assert_contains "$out" "spawned $id" "spawn did not report success" + + assert_grep 'beads_id=bead-42' "$HOME_DIR/state/$id.meta" \ + "spawn did not record beads_id=bead-42 in the task's meta" + + for _ in $(seq 1 30); do [ -s "$calls_log" ] && break; sleep 0.1; done + assert_grep "show bead-42" "$calls_log" \ + "fm-bead-stamp.sh did not invoke 'task show bead-42'" + assert_grep "set-state bead-42 dispatch=sent" "$calls_log" \ + "fm-bead-stamp.sh did not stamp dispatch=sent on bead-42" + assert_grep "set-state bead-42 lifecycle=sent" "$calls_log" \ + "fm-bead-stamp.sh did not stamp lifecycle=sent on bead-42" + pass "a spawn with --beads records beads_id= in meta and stamps the bead dispatch=sent/lifecycle=sent" +} + +test_spawn_without_beads_flag_omits_meta_and_skips_stamp() { + local rec id out calls_log + id=spawn-beads-absent-z2 + rec=$(make_spawn_case spawn-beads-absent "$id") + read_spawn_record "$rec" + calls_log="$CASE_DIR/task-calls.log" + add_beads_task_mock "$FAKEBIN_DIR" "$calls_log" + + out=$(run_case_spawn "$id") + expect_code 0 "$?" "spawn should succeed without --beads" + assert_contains "$out" "spawned $id" "spawn did not report success" + + grep -q '^beads_id=' "$HOME_DIR/state/$id.meta" 2>/dev/null \ + && fail "spawn recorded a beads_id= despite no --beads flag" + [ -e "$calls_log" ] \ + && fail "fm-bead-stamp.sh was invoked despite no --beads flag: $(cat "$calls_log")" + pass "a spawn without --beads records no beads_id= and never invokes the bead stamp" +} + +test_spawn_beads_records_meta_and_stamps_bead +test_spawn_without_beads_flag_omits_meta_and_skips_stamp + +echo "# all fm-spawn-beads tests passed"