diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 42990edd04..4eaf3ba3f1 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -37,6 +37,7 @@ It never tears down a task, merges a PR, dispatches new work, steers a worker, a Keep the default local-only read unless the captain asks to include PRs. For registered secondmates, use the snapshot's structured-home classification and provenance. A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + The snapshot invocation enables recovery-grade endpoint and worker liveness so a non-held, terminal-looking in-flight row with both signals verified absent appears under `gates` as a measured-liveness gate; held rows and active-worker rows do not receive that gate. Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`. Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 5a23bec367..7713b101e7 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -22,6 +22,10 @@ # This wrapper consumes canonical status decisions plus canonically normalized # backlog roles, unresolved blockers, and captain actionability. It never infers # decisions from report or visual-review prose or reimplements snapshot semantics. +# Each invocation requests recovery-grade endpoint and worker liveness from the +# canonical snapshot. A non-secondmate in-flight row that looks terminal, is not +# held, and has both endpoint and worker verified absent is projected as a +# measured-liveness gate; held rows and rows with an active worker are excluded. # # Main-home inventory validity comes from the canonical snapshot's main_inventory # object (orphan structured in-flight without meta, unstructured current rows). @@ -169,12 +173,12 @@ command -v jq >/dev/null 2>&1 || { echo "fm-bearings-snapshot: jq not found" >&2 NOW=${FM_BEARINGS_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} if [ "$ALL_LANDED" = 1 ] || [ "$ALL_SECONDMATES" = 1 ]; then if [ "$ALL_LANDED" = 1 ]; then - SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_SECONDMATES=0 FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 "$FLEET" --json) || exit $? + SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_MEASURE_AGENT_STATE=1 FM_SNAPSHOT_SECONDMATES=0 FM_SNAPSHOT_SECONDMATE_LANDED_PER_HOME=0 "$FLEET" --json) || exit $? else - SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_SECONDMATES=0 "$FLEET" --json) || exit $? + SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_MEASURE_AGENT_STATE=1 FM_SNAPSHOT_SECONDMATES=0 "$FLEET" --json) || exit $? fi else - SNAP=$(FM_SNAPSHOT_NOW="$NOW" "$FLEET" --json) || exit $? + SNAP=$(FM_SNAPSHOT_NOW="$NOW" FM_SNAPSHOT_MEASURE_AGENT_STATE=1 "$FLEET" --json) || exit $? fi HOME_LABEL=$(printf '%s' "$SNAP" | jq -er '.fm_home | strings | split("/") | (.[-2:] | join("/"))') \ || { echo "fm-bearings-snapshot: invalid canonical snapshot" >&2; exit 1; } @@ -301,6 +305,15 @@ MODEL=$(printf '%s' "$SNAP" | jq \ --argjson candidate_prs "$CANDIDATE_PRS" ' def trunc($n): if . == null then null else (tostring | gsub("\\s+"; " ") | if (length > $n) then (.[:$n] + "…") else . end) end; + def measured_liveness_gate: + .kind != "secondmate" + and .backlog.state == "in_flight" + and .backlog.current_role != "held" + and ((.current_state.state == "done" or .current_state.state == "failed") + or ((.hints.last_event_text // "") | test("^(done|failed):"))) + and .liveness.activity == "absent" + and .liveness.endpoint.presence == "verified_absent" + and .liveness.worker.presence == "verified_absent"; def round_robin_landed($n): . as $groups | [range(0; (($groups | map(length) | max) // 0)) as $i @@ -370,6 +383,7 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select(.kind != "secondmate") | select(.backlog.current_role != "program") | select(.backlog.current_role != "held" or .current_state.state == "working") + | select(measured_liveness_gate | not) | {id, kind, state: .current_state.state, doing: ((.current_state.detail // "") as $d @@ -394,6 +408,10 @@ MODEL=$(printf '%s' "$SNAP" | jq \ reason:"main inventory", owner:"(main)"}] else [] end) + + [ .tasks[] + | select(measured_liveness_gate) + | {id,title:((.backlog.title // .id) | trunc(60)),blocked_by:"-", + reason:("measured liveness: " + .liveness.activity + "; endpoint=" + .liveness.endpoint.presence + "; worker=" + .liveness.worker.presence | trunc(120)),owner:"(main)"} ] + [ .backlog.records[] | . as $record | select(.structured and diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index bc7f1a3c47..a4771cd1dc 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -30,8 +30,10 @@ # against current_state; hints.pending_decision and hints.blocked_event are # booleans derived from that set. # endpoint.exists is the cheap backend endpoint-presence read. -# endpoint.agent_alive is populated for secondmates only, where it is useful -# return-channel supervision data; other tasks use "not_checked". +# endpoint.agent_alive is populated for secondmates only by default, where it +# is useful return-channel supervision data; other tasks use "not_checked". +# FM_SNAPSHOT_MEASURE_AGENT_STATE=1 asks the existing recovery-grade backend +# classifier for every local task and adds normalized endpoint/worker liveness. # scout_reports[]: present data//report.md pointers. # main_inventory: {valid,reason,orphan_in_flight[],unstructured_current_count} - # main-home current-inventory checks shared with secondmate_home_summary_json @@ -66,6 +68,14 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" BACKLOG="$DATA/backlog.md" SNAPSHOT_NOW=${FM_SNAPSHOT_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} +FM_SNAPSHOT_MEASURE_AGENT_STATE=${FM_SNAPSHOT_MEASURE_AGENT_STATE:-0} +case "$FM_SNAPSHOT_MEASURE_AGENT_STATE" in + 0|1) ;; + *) + echo "fm-fleet-snapshot: FM_SNAPSHOT_MEASURE_AGENT_STATE must be 0 or 1" >&2 + exit 2 + ;; +esac if [ -n "${FM_SNAPSHOT_NOW_EPOCH:-}" ]; then SNAPSHOT_EPOCH=$FM_SNAPSHOT_NOW_EPOCH else @@ -403,7 +413,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG task_json_lines() { local meta id kind harness mode yolo project worktree home projects backend target status_log report_path local remote_host remote_root remote_state remote_rc remote_home_present - local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json + local pr pr_source event_json current_json endpoint_exists agent_alive agent_state meta_json status_json report_json worktree_json home_json local last_event_raw current_state current_source pending_decision blocked_event report_present=0 pr_from_status local open_decisions_tsv open_decisions_json @@ -504,15 +514,27 @@ task_json_lines() { fi else if [ -n "$target" ]; then - if fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then + if [ "$kind" = secondmate ] || [ "$FM_SNAPSHOT_MEASURE_AGENT_STATE" = 1 ]; then + agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null || printf unreadable) + case "$agent_state" in + alive) endpoint_exists=true; agent_alive=alive ;; + dead) endpoint_exists=true; agent_alive=dead ;; + missing) endpoint_exists=false; agent_alive=dead ;; + *) + if fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then + endpoint_exists=true + else + endpoint_exists=null + fi + agent_alive=unknown + ;; + esac + elif fm_backend_target_exists "$backend" "$target" "fm-$id" >/dev/null 2>&1; then endpoint_exists=true else endpoint_exists=false fi fi - if [ "$kind" = secondmate ] && [ -n "$target" ]; then - agent_alive=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null || printf unknown) - fi fi [ -f "$report_path" ] && report_present=1 || report_present=0 @@ -581,6 +603,17 @@ task_json_lines() { elif $agent_alive == "alive" or $agent_alive == "dead" then $agent_alive else "unknown" end), observed_at:$observed_at,freshness:"fresh"}, + liveness:{ + endpoint:{presence:(if $endpoint_exists == true then "verified_present" + elif $endpoint_exists == false then "verified_absent" + else "unverified" end)}, + worker:{presence:(if $agent_alive == "alive" then "verified_present" + elif $agent_alive == "dead" then "verified_absent" + else "unverified" end)}, + activity:(if $endpoint_exists == false and $agent_alive == "dead" then "absent" + elif $agent_alive == "alive" then "present" + else "unverified" end) + }, pr:{url:($pr | if . == "" then null else . end),source:$pr_source}, hints:{ pending_decision:$pending_decision, diff --git a/docs/architecture.md b/docs/architecture.md index 9c56d25176..e62a873ee0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,7 @@ Decision-only events such as `resolved` never become current state or leak their In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. +When `FM_SNAPSHOT_MEASURE_AGENT_STATE=1` is set, its local task rows also expose normalized endpoint and worker liveness under `liveness`; Bearings enables this mode and projects a non-held, terminal-looking in-flight row with both endpoint and worker verified absent into Charted Next as a measured-liveness gate, while held rows and rows with an active worker remain in their ordinary sections. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. diff --git a/docs/decision-hold-lifecycle.md b/docs/decision-hold-lifecycle.md index 234055aec3..718e479d72 100644 --- a/docs/decision-hold-lifecycle.md +++ b/docs/decision-hold-lifecycle.md @@ -53,7 +53,7 @@ The final verification commands and their exact summarized outputs follow. ```text $ bash tests/fm-decision-hold-lifecycle.test.sh -ok - report-only unresolved decision is reproduced and completion refuses before loss +ok - verified-absent in-flight work gates while held and active workers avoid measured-liveness gates, selector neutralization goes RED, and completion refuses before report loss ok - non-forced scout teardown always requires durable inventory verification ok - captain holds are idempotent, distinct, teardown-safe, Bearings-visible, and durably routed before close ok - completion and verification validate origins before constructing paths diff --git a/tests/fm-decision-hold-lifecycle.test.sh b/tests/fm-decision-hold-lifecycle.test.sh index 0ef84c4a6f..56b4e654ff 100755 --- a/tests/fm-decision-hold-lifecycle.test.sh +++ b/tests/fm-decision-hold-lifecycle.test.sh @@ -31,10 +31,61 @@ EOF printf '%s\n' "$home" } -run_bearings() { # - local home=$1 +run_bearings_with() { # + local bearings=$1 home=$2 PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_BEARINGS_NOW=2026-07-14T12:00:00Z \ - "$BEARINGS" --json + "$bearings" --json +} + +run_bearings() { # + run_bearings_with "$BEARINGS" "$1" +} + +liveness_property_holds() { # + printf '%s' "$1" | jq -e --arg phantom "$2" --arg held "$3" --arg active "$4" ' + (.gates | any( + .id == $phantom + and .reason == "measured liveness: absent; endpoint=verified_absent; worker=verified_absent" + )) + and (.gates | any(.id == $held and .reason == "captain route choice pending")) + and (.gates | any( + .id == $held and (.reason | startswith("measured liveness:")) + ) | not) + and (.gates | any(.id == $active) | not) + ' >/dev/null +} + +install_liveness_tmux_fixture() { # + local home=$1 active_id=$2 + cat > "$home/fakebin/tmux" <<'EOF' +#!/usr/bin/env bash +target= +previous= +for argument in "$@"; do + if [ "$previous" = -t ]; then target=$argument; fi + previous=$argument +done +case "${1:-}" in + list-windows) + printf 'fm-%s\n' "${FM_TEST_ACTIVE_ID:?}" + ;; + display-message) + [ "$target" = "firstmate:fm-${FM_TEST_ACTIVE_ID:?}" ] || exit 1 + case "${*: -1}" in + '#{pane_id}') printf '%%1\n' ;; + '#{pane_current_command}') printf 'codex\n' ;; + '#{pane_tty}') printf '\n' ;; + *) printf 'codex\n' ;; + esac + ;; + capture-pane) + [ "$target" = "firstmate:fm-${FM_TEST_ACTIVE_ID:?}" ] || exit 1 + printf 'working\n' + ;; + *) exit 1 ;; +esac +EOF + chmod +x "$home/fakebin/tmux" } run_teardown() { # @@ -49,13 +100,18 @@ run_teardown() { # # no held backlog item or open status exists, and the authoritative Bearings view # correctly omits it. Completion must now refuse before teardown can erase the source. test_uninventoried_report_decision_refuses_completion() { - local home id json rc + local home id held_id active_id mutated_bin json mutated_json rc home=$(make_home omitted-decision) id=sample-route-review - mkdir -p "$home/data/$id" + held_id=sample-held-review + active_id=sample-active-review + mkdir -p "$home/data/$id" "$home/data/$held_id" "$home/data/$active_id" \ + "$home/projects/active-scratch" cat > "$home/data/backlog.md" < "$home/state/$id.status" + printf 'needs-decision [key=route]: captain route choice pending\n' > "$home/state/$held_id.status" + printf 'working: continuing sample routing\n' > "$home/state/$active_id.status" cat > "$home/data/$id/report.md" <<'EOF' # Sample route review @@ -76,12 +148,33 @@ The evidence is complete. The captain still needs to choose route north or route south before follow-up work starts. EOF - json=$(run_bearings "$home") || fail "Bearings failed for unresolved-decision regression" - printf '%s' "$json" | jq -e ' - (.decisions_open | length) == 0 - and (.gates | length) == 0 - and (.reports | any(.id == "sample-route-review")) - ' >/dev/null || fail "the pre-policy omission shape was not reproduced: $json" + install_liveness_tmux_fixture "$home" "$active_id" + json=$(FM_TEST_ACTIVE_ID="$active_id" run_bearings "$home") \ + || fail "Bearings failed for unresolved-decision regression" + liveness_property_holds "$json" "$id" "$held_id" "$active_id" \ + || fail "measured liveness did not distinguish the phantom, held, and active lanes without hiding the held gate: $json" + printf '%s' "$json" | jq -e --arg id "$id" ' + (.decisions_open | any(.id == $id) | not) and (.reports | any(.id == $id)) + ' >/dev/null || fail "phantom regression lost its report-only decision shape: $json" + + mutated_bin="$home/mutated-bin" + cp -R "$ROOT/bin" "$mutated_bin" + sed 's/and \.liveness\.activity == "absent"/and false/' \ + "$BEARINGS" > "$mutated_bin/fm-bearings-snapshot.sh" + chmod +x "$mutated_bin/fm-bearings-snapshot.sh" + cmp -s "$BEARINGS" "$mutated_bin/fm-bearings-snapshot.sh" \ + && fail "measured-liveness selector mutation did not alter the executable" + mutated_json=$(FM_TEST_ACTIVE_ID="$active_id" \ + run_bearings_with "$mutated_bin/fm-bearings-snapshot.sh" "$home") \ + || fail "selector-neutralized Bearings execution failed" + if liveness_property_holds "$mutated_json" "$id" "$held_id" "$active_id"; then + fail "neutralizing the measured-liveness selector left the guarded property green: $mutated_json" + fi + printf '%s' "$mutated_json" | jq -e --arg id "$id" --arg held "$held_id" --arg active "$active_id" ' + (.gates | any(.id == $id) | not) + and (.gates | any(.id == $held and .reason == "captain route choice pending")) + and (.gates | any(.id == $active) | not) + ' >/dev/null || fail "selector neutralization went RED for a reason other than removal of the phantom gate: $mutated_json" set +e run_teardown "$home" "$id" > "$home/teardown.out" 2> "$home/teardown.err" @@ -90,7 +183,7 @@ EOF [ "$rc" -ne 0 ] || fail "completed investigation teardown erased a report-only unresolved decision" assert_present "$home/state/$id.meta" "refused completion must preserve investigation metadata" assert_grep "REFUSED" "$home/teardown.err" "refusal must be explicit" - pass "report-only unresolved decision is reproduced and completion refuses before loss" + pass "verified-absent in-flight work gates while held and active workers avoid measured-liveness gates, selector neutralization goes RED, and completion refuses before report loss" } tasks_in() { #