diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 3cec3a0db9b..fca6144a857 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -280,6 +280,7 @@ SEP=' ยท ' emit() { # [detail] local line="state: $1${SEP}source: $2" [ -n "${3:-}" ] && line="$line${SEP}$3" + [ -z "${EMIT_NOTE:-}" ] || line="$line${SEP}$EMIT_NOTE" printf '%s\n' "$line" exit 0 } @@ -305,6 +306,19 @@ KIND=$(meta_value kind) HARNESS=$(meta_value harness) REMOTE_HOST=$(meta_value remote_host) [ -n "$KIND" ] || KIND=ship +# The task incarnation's start epoch (bin/fm-spawn.sh records task_started= on a +# fresh spawn and carries it across relaunches). A record without it falls back +# to its spawn_gen= epoch; neither leaves attribution unbounded. +TASK_START=$(meta_value task_started) +case "$TASK_START" in + ''|*[!0-9]*) + TASK_START=$(meta_value spawn_gen) + TASK_START=${TASK_START#s} + TASK_START=${TASK_START%%.*} + case "$TASK_START" in ''|*[!0-9]*) TASK_START='' ;; esac + ;; +esac + # A torn-down (or never-created) worktree has no current state to read. A # remote secondmate's recorded worktree is a path on ITS host, so the local @@ -1071,6 +1085,17 @@ NM_DAEMON_ANSWER="" RUN_DEAD_DAEMON="" COARSE_STATUS="" SELECTED_RUN_ID="" +# The id of this branch's newest run when it is not this task incarnation's own +# (fm_nm_run_in_incarnation in bin/fm-nm-run-lib.sh). It is named on whichever +# current-evidence line answers instead, so the ignored record stays visible. +RUN_BEFORE_TASK="" +note_unattributed_run() { # + if fm_nm_run_created_epoch "$1" >/dev/null; then + EMIT_NOTE="run $1 predates this task: not attributed" + else + EMIT_NOTE="run $1 creation time unreadable: not attributed" + fi +} # Scouts and secondmates never drive a no-mistakes validation of their own # worktree, so skip the lookup for them and read state from pane/log directly. if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/null 2>&1; then @@ -1157,45 +1182,58 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n ;; esac fi - if [ -n "$selected_detail" ]; then - RUN_OUT=$selected_detail + if ! fm_nm_run_in_incarnation "$selected_id" "$TASK_START"; then + RUN_BEFORE_TASK=$selected_id + note_unattributed_run "$selected_id" + run_choice=earlier else - RUN_OUT=$(fm_nm_run_checked "$WT" "$NM_TIMEOUT" axi status --run "$selected_id") \ - || emit unknown run-step "selected run unreadable; run ids: $candidate_ids" - fi - if [ "$(strip_quotes "$(nm_field id)")" != "$selected_id" ] \ - || [ "$(strip_quotes "$(nm_field branch)")" != "$CREW_BRANCH" ]; then - emit unknown run-step "selected run unavailable or mismatched; run ids: $candidate_ids" - fi - case "$(strip_quotes "$(nm_field status)")" in - pending|running|fixing|ci|awaiting_approval|fix_review|completed|failed|cancelled) ;; - *) emit unknown run-step "selected run status unverified; run ids: $candidate_ids" ;; - esac - if fm_nm_run_is_active "$RUN_OUT"; then current_class=live; else current_class=terminal; fi - if [ "$(fm_nm_run_status_class "$selected_status")" != "$current_class" ]; then - emit unknown run-step "selected run status disagrees with inventory; run ids: $candidate_ids" - fi - if nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ - || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_answered_down; }; then - HAVE_RUN=1 - elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then - if fm_nm_run_is_active "$RUN_OUT" \ - && [ "$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "$(strip_quotes "$(nm_field head)")")" = running ]; then - # The anchor PROVED code identity; only liveness can still fail, so - # a dead daemon is reported as such rather than as an identity - # failure, and a parked run keeps its gate and findings. + if [ -n "$selected_detail" ]; then + RUN_OUT=$selected_detail + else + RUN_OUT=$(fm_nm_run_checked "$WT" "$NM_TIMEOUT" axi status --run "$selected_id") \ + || emit unknown run-step "selected run unreadable; run ids: $candidate_ids" + fi + if [ "$(strip_quotes "$(nm_field id)")" != "$selected_id" ] \ + || [ "$(strip_quotes "$(nm_field branch)")" != "$CREW_BRANCH" ]; then + emit unknown run-step "selected run unavailable or mismatched; run ids: $candidate_ids" + fi + case "$(strip_quotes "$(nm_field status)")" in + pending|running|fixing|ci|awaiting_approval|fix_review|completed|failed|cancelled) ;; + *) emit unknown run-step "selected run status unverified; run ids: $candidate_ids" ;; + esac + if fm_nm_run_is_active "$RUN_OUT"; then current_class=live; else current_class=terminal; fi + if [ "$(fm_nm_run_status_class "$selected_status")" != "$current_class" ]; then + emit unknown run-step "selected run status disagrees with inventory; run ids: $candidate_ids" + fi + if nm_run_head_matches_worktree || fm_nm_run_is_pipeline_owned_active "$RUN_OUT" \ + || { fm_nm_run_is_executing "$RUN_OUT" && ! nm_daemon_answered_down; }; then HAVE_RUN=1 - if ! fm_nm_run_is_parked "$RUN_OUT" && nm_daemon_answered_down; then - RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" + elif [ -z "$(fm_nm_resolve_commit "$WT" "$(strip_quotes "$(nm_field head)")")" ]; then + if fm_nm_run_is_active "$RUN_OUT" \ + && [ "$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$(nm_runs_list)" "$(strip_quotes "$(nm_field head)")" "$TASK_START")" = running ]; then + # The anchor PROVED code identity; only liveness can still fail, so + # a dead daemon is reported as such rather than as an identity + # failure, and a parked run keeps its gate and findings. + HAVE_RUN=1 + if ! fm_nm_run_is_parked "$RUN_OUT" && nm_daemon_answered_down; then + RUN_DEAD_DAEMON="no-mistakes daemon unreachable; last run record $(strip_quotes "$(nm_field status)") - unverified" + fi + else + emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi - else - emit unknown run-step "selected run code identity unverified; run ids: $candidate_ids" fi + SELECTED_RUN_ID=$selected_id fi - SELECTED_RUN_ID=$selected_id ;; esac - if [ "$run_choice" != status-absent ] && [ "$HAVE_RUN" = 0 ] && [ -z "$SELECTED_RUN_ID" ]; then + if [ "$run_choice" != status-absent ] && [ "$HAVE_RUN" = 0 ] && [ -z "$SELECTED_RUN_ID" ] && [ -z "$RUN_BEFORE_TASK" ]; then + run_branch=$(strip_quotes "$(nm_field branch)") + if [ "$run_branch" = "$CREW_BRANCH" ] && ! fm_nm_run_in_incarnation "$(strip_quotes "$(nm_field id)")" "$TASK_START"; then + RUN_BEFORE_TASK=$(strip_quotes "$(nm_field id)") + note_unattributed_run "$RUN_BEFORE_TASK" + fi + fi + if [ "$run_choice" != status-absent ] && [ "$HAVE_RUN" = 0 ] && [ -z "$SELECTED_RUN_ID" ] && [ -z "$RUN_BEFORE_TASK" ]; then run_branch=$(strip_quotes "$(nm_field branch)") # Head equality, the pipeline-owned parked-run exemption, or executing # regardless of head: a live run on this branch is current even after a @@ -1213,7 +1251,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # A live replacement also needs an id-addressed status read: a bare # "running" row cannot tell working from waiting at a gate. runs_list_once - ledger_status=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST") + ledger_status=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST" "" "$TASK_START") if fm_nm_run_is_active "$RUN_OUT"; then if [ "$(fm_nm_run_status_class "$ledger_status")" = terminal ]; then emit unknown run-step "run records disagree; run ids: $(strip_quotes "$(nm_field id)"), competing identity unavailable" @@ -1236,7 +1274,7 @@ if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/n # itself did not respond, so retrying it immediately with a second # bounded call would just double the wait for no better answer. runs_list_once - COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST") + COARSE_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST" "" "$TASK_START") if [ -n "$COARSE_STATUS" ]; then HAVE_RUN=1 # A branch-matching answer the strict rule rejected is this branch's @@ -1407,7 +1445,7 @@ if [ "$HAVE_RUN" = 1 ]; then NEWEST_SHA=${NEWEST_REST%%|*} NEWEST_PR=${NEWEST_REST#*|} NEWEST_ROW_AGREES=0 - LEDGER_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST") + LEDGER_STATUS=$(fm_nm_runs_status_for_worktree "$WT" "$CREW_BRANCH" "$RUNS_LIST" "" "$TASK_START") if [ "$RUN_SOURCE" = full ] && [ -n "$RUN_ID" ] \ && [ "$(strip_quotes "$(nm_field branch)")" = "$CREW_BRANCH" ] \ && [ "$NEWEST_STATUS" = "$ATTRIBUTED_LEDGER_STATUS" ] \ diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index 8060ee25825..a16397bbc29 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -190,6 +190,43 @@ fm_nm_head_matches_worktree() { # git -C "$wt" merge-base --is-ancestor "$local_full" "$run_full" 2>/dev/null } +# Task incarnation: code identity alone cannot tell this task's run from one an +# earlier task finished on the same branch at the same head, which is exactly +# what a refresh task reusing an existing PR branch sees. A run created before +# the task's start epoch is therefore never the task's result. no-mistakes run +# ids are ULIDs, whose first ten Crockford base32 characters encode the creation +# time in milliseconds (the same instant as the database's created_at). +# Prints the creation epoch in whole seconds; nonzero when $1 is not a ULID. +fm_nm_run_created_epoch() { # + local id alphabet=0123456789ABCDEFGHJKMNPQRSTVWXYZ ms=0 i prefix + id=$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]') + [ "${#id}" -eq 26 ] || return 1 + case "$id" in [0-7]*) ;; *) return 1 ;; esac + case "$id" in *[!0-9ABCDEFGHJKMNPQRSTVWXYZ]*) return 1 ;; esac + for ((i = 0; i < 10; i++)); do + prefix=${alphabet%%"${id:i:1}"*} + ms=$((ms * 32 + ${#prefix})) + done + printf '%s' $((ms / 1000)) +} + +# 0 when run $1 belongs to the task incarnation that started at epoch $2: no +# recorded start (no bound), or a creation time at or after it. A run whose +# creation time cannot be read cannot be proven to be the task's own and is +# refused whenever a start is recorded. +fm_nm_run_in_incarnation() { # + local created + [ -n "${2:-}" ] || return 0 + created=$(fm_nm_run_created_epoch "$1") || return 1 + [ "$created" -ge "$2" ] +} + +# Local "YYYY-MM-DD HH:MM" of epoch $1, the resolution and timezone of the +# `no-mistakes runs` creation column (the CLI runs in this process's timezone). +fm_nm_epoch_ledger_minute() { # + date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null || date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null +} + # Liveness class of a recorded ledger status word. # The coarse `no-mistakes runs` ledger emits database status words; an # `axi status` run object reports its terminal result through its own outcome @@ -574,13 +611,19 @@ fm_nm_runs_row_valid() { # # name matches. The one live bind is the EXECUTING record on the `axi status` # route (fm_nm_run_is_executing above), which the caller pairs with its own # liveness evidence. +# A task-start bound requires an answering row later than its start minute; +# an older anchor can still prove code identity without answering for the task. # Read-only: git reads resolve objects in place; custody never changes. -fm_nm_runs_status_for_worktree() { # [expected-head] - local wt=$1 branch=$2 list=$3 expected_head=${4:-} +fm_nm_runs_status_for_worktree() { # [expected-head] [task-start-epoch] + local wt=$1 branch=$2 list=$3 expected_head=${4:-} task_start=${5:-} start_minute='' local local_full row_full row st br sha day clock pr extra pending_st='' local decided='' local_full=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 0 [ -n "$list" ] || return 0 + if [ -n "$task_start" ]; then + start_minute=$(fm_nm_epoch_ledger_minute "$task_start") || return 0 + [ -n "$start_minute" ] || return 0 + fi while IFS= read -r row; do row=$(fm_nm_trim "$row") [ -n "$row" ] || continue @@ -596,6 +639,9 @@ fm_nm_runs_status_for_worktree() { # [ex fi break fi + if [ -n "$start_minute" ] && ! [[ "$start_minute" < "$day $clock" ]]; then + break + fi if [ -n "$expected_head" ]; then case "$expected_head" in *[!A-Fa-f0-9]*|'') break ;; esac [ "${#expected_head}" -ge 7 ] && [ "${#expected_head}" -le 40 ] || break diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index da51b498ce3..389dad4f74b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -734,6 +734,7 @@ # success line and state/.meta omit them. # Every fresh spawn or relaunch records a new spawn_gen= incarnation token so durable # consumers can distinguish a replacement worker that reuses the same task id. +# task_started= records the fresh task start and survives relaunch, bounding run attribution. # When the home session's frozen trace-context decision is enabled (see # docs/configuration.md and bin/fm-trace-context-lib.sh), the meta also records # one W3C traceparent= carrier, the same value injected into the pane as @@ -7267,7 +7268,20 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W [ "$BACKEND" = t3code ] && META_WINDOW=$W -SPAWN_GEN="s$(date +%s).${BASHPID:-$$}.$RANDOM" +SPAWN_EPOCH=$(date +%s) +SPAWN_GEN="s$SPAWN_EPOCH.${BASHPID:-$$}.$RANDOM" +TASK_STARTED=$SPAWN_EPOCH +if [ "$RELAUNCH" -eq 1 ]; then + RELAUNCH_TASK_STARTED=$(fm_meta_get "$RELAUNCH_META" task_started) + case "$RELAUNCH_TASK_STARTED" in + ''|*[!0-9]*) + RELAUNCH_TASK_STARTED=$(fm_meta_get "$RELAUNCH_META" spawn_gen) + RELAUNCH_TASK_STARTED=${RELAUNCH_TASK_STARTED#s} + RELAUNCH_TASK_STARTED=${RELAUNCH_TASK_STARTED%%.*} + ;; + esac + case "$RELAUNCH_TASK_STARTED" in ''|*[!0-9]*) ;; *) TASK_STARTED=$RELAUNCH_TASK_STARTED ;; esac +fi SPAWN_META_PATH="$STATE/$ID.meta" if [ "$SPAWN_META_LOCK_HELD" != 1 ]; then SPAWN_META_LOCK=$(fm_meta_lock_path "$STATE/$ID.meta") || exit 1 @@ -7284,7 +7298,7 @@ SPAWN_META_PATH=$SPAWN_META_TMP preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness hermes_profile hermes_home kind mode yolo branch tasktmp base_branch upstream_consent forge backup_remote backup_prefix model effort opencode_version account account_provider omp_profile busy_gen spawn_gen approval_mode traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id paseo_terminal_id paseo_workspace_id rex_server rex_session_id rex_window_id rex_block_id t3_thread_id t3_project_id tern_session_id tern_tab_id tern_block_id hc_session hc_terminal home projects control_relaunch_tx polytoken_session polytoken_port polytoken_credential", keys, " ") + split("window endpoint_task_id worktree project harness hermes_profile hermes_home kind mode yolo branch tasktmp base_branch upstream_consent forge backup_remote backup_prefix model effort opencode_version account account_provider omp_profile busy_gen spawn_gen task_started approval_mode traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id paseo_terminal_id paseo_workspace_id rex_server rex_session_id rex_window_id rex_block_id t3_thread_id t3_project_id tern_session_id tern_tab_id tern_block_id hc_session hc_terminal home projects control_relaunch_tx polytoken_session polytoken_port polytoken_credential", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) @@ -7330,6 +7344,7 @@ preserve_relaunch_meta() { [ "$HARNESS" != hermes ] || { echo "hermes_profile=$HERMES_PROFILE"; echo "hermes_home=$HERMES_PROFILE_HOME"; } [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" echo "spawn_gen=$SPAWN_GEN" + echo "task_started=$TASK_STARTED" # Default-off writes no traceparent= line. # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index bbf366b6178..4bbb615e10d 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -509,6 +509,7 @@ normalize_meta() { # -e 's|^herdr_tab_id=.*$|herdr_tab_id=|' \ -e 's|^herdr_pane_id=.*$|herdr_pane_id=|' \ -e 's|^spawn_gen=.*$|spawn_gen=|' \ + -e 's|^task_started=.*$|task_started=|' \ "$1" } diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 7058776bf0c..6a106550dfc 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -535,6 +535,26 @@ test_traced_relaunch_keeps_an_armed_pr_poll_authenticated() { pass "fm-control relaunch: a traced replacement keeps an armed PR poll authenticated" } +# A relaunch continues the same task, so its validation runs stay attributable: +# the task start carries forward, and a record older than task_started= derives +# it from the spawn generation it was first launched with. +test_relaunch_carries_the_task_start() { + local dir out rc + dir=$(new_case task-start rl43) + add_ship_task "$dir" rl43 claude + printf '%s\n' 'spawn_gen=s1700000000.1.1' >> "$dir/home/state/rl43.meta" + out=$(run_control "$dir" rl43 relaunch --note "continuing after a stall"); rc=$? + expect_code 0 "$rc" "a relaunch of a legacy record should succeed"$'\n'"$out" + [ "$(meta_field "$dir" rl43 task_started)" = 1700000000 ] \ + || fail "a legacy record must derive its task start from its prior spawn generation" + [ "$(meta_field "$dir" rl43 spawn_gen)" != s1700000000.1.1 ] || fail "a relaunch must mint a new spawn generation" + out=$(run_control "$dir" rl43 relaunch --note "continuing again"); rc=$? + expect_code 0 "$rc" "a second relaunch should succeed"$'\n'"$out" + [ "$(meta_field "$dir" rl43 task_started)" = 1700000000 ] || fail "the task start must survive every relaunch" + [ "$(grep -c '^task_started=' "$dir/home/state/rl43.meta")" = 1 ] || fail "the task start must be recorded once" + pass "fm-control relaunch: the task start carries across relaunches" +} + test_relaunch_serializes_concurrent_durable_metadata_publication() { local dir control_pid link_pid rc i=0 traceparent prepare launch_release waiting ready release dir=$(new_case metadata-race rl28) @@ -3236,3 +3256,5 @@ test_herdr_rebind_failure_from_a_plain_shell_names_the_real_cause test_relaunch_reverifies_an_already_in_flight_item_instead_of_rewriting_it test_relaunch_moves_a_drifted_item_back_in_flight test_away_branch_relaunch_accepts_existing_dispatchable_items + +test_relaunch_carries_the_task_start diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a68e9b451a9..0272a8609cd 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -6977,4 +6977,182 @@ test_live_ledger_row_after_selection_reports_unknown test_unverifiable_run_selection_reports_unknown test_legacy_conflicting_run_records_report_unknown +# A ULID run id created at : no-mistakes run ids are ULIDs, whose +# leading ten characters encode the creation time in milliseconds. +ulid_at() { # -> run id + local ms=$(($1 * 1000)) alphabet=0123456789ABCDEFGHJKMNPQRSTVWXYZ time='' i + for i in 1 2 3 4 5 6 7 8 9 10; do + time="${alphabet:$((ms % 32)):1}$time" + ms=$((ms / 32)) + done + printf '%sABCDEFGHJKMNPQRS' "$time" +} + +# The ledger's creation column, in the same local time the real CLI prints. +ledger_minute() { # + date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null || date -d "@$1" '+%Y-%m-%d %H:%M' +} + +# One selected-route case: the branch's only run is , finished passed at +# the worktree's own head, exactly the shape an earlier task leaves behind. +make_incarnation_case() { # ... + local name=$1 run_id=$2 d short + shift 2 + reset_fakes + d=$(new_case "$name") + make_repo_on_branch "$d/wt" fm/refresh + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/refresh.meta" "window=fm:fm-refresh" "worktree=$d/wt" "kind=ship" "harness=claude" "$@" + short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + FM_FAKE_AXI_STATUS="$(run_passed_with_override fm/refresh | sed "s/01RUN/$run_id/")" + FM_FAKE_AXI_STATUS_RUN=$FM_FAKE_AXI_STATUS + FM_FAKE_AXI_HOME="count: 1 of 1 total +runs[1]{id,branch,status,head,pr}: + \"$run_id\",fm/refresh,completed,$short,\"https://github.com/o/r/pull/1\"" + FM_FAKE_PR_STATE=OPEN + FM_FAKE_PR_MERGED=false +} + +test_refresh_task_does_not_inherit_an_earlier_run() { + local now d out gen + now=$(date +%s) + make_incarnation_case refresh-busy "$(ulid_at $((now - 3600)))" "task_started=$now" + d=$TMP_ROOT/refresh-busy + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" refresh) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" refresh busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'state: done' "an earlier task's finished run must not read as this task's result: $out" + assert_contains "$out" 'state: working' 'the refresh worker is still working' + assert_contains "$out" 'source: pane' 'current pane evidence answers instead of the earlier run' + assert_contains "$out" 'predates this task' 'the ignored run is named with its reason' + pass 'a refresh task does not inherit an earlier task run on the same branch and head' +} + +test_refresh_task_status_log_answers_over_an_earlier_run() { + local now d out + now=$(date +%s) + make_incarnation_case refresh-idle "$(ulid_at $((now - 3600)))" "task_started=$now" + d=$TMP_ROOT/refresh-idle + arm_idle_record "$d/state" refresh >/dev/null + printf 'working [at=%s]: resolving merge conflicts\n' "$now" > "$d/state/refresh.status" + out=$(run_crew_state "$d" refresh) + assert_contains "$out" 'state: working' "the worker's own status answers: $out" + assert_contains "$out" 'source: status-log' 'no run of this incarnation exists yet' + assert_contains "$out" 'resolving merge conflicts' 'the current work detail is preserved' + pass "a refresh task's status log answers over an earlier task run" +} + +test_run_created_after_task_start_is_attributed() { + local now d out + now=$(date +%s) + make_incarnation_case own-run "$(ulid_at $((now + 60)))" "task_started=$now" + d=$TMP_ROOT/own-run + out=$(run_crew_state "$d" refresh) + assert_contains "$out" 'state: done' "this task's own finished run answers: $out" + assert_contains "$out" 'source: run-step' 'the own run keeps run-step attribution' + pass "a run created after the task started is this task's run" +} + +test_relaunch_keeps_the_task_incarnation() { + local now d out + now=$(date +%s) + # task_started survives a relaunch; spawn_gen is the relaunch's own token. + make_incarnation_case relaunched "$(ulid_at $((now - 600)))" \ + "task_started=$((now - 3600))" "spawn_gen=s$now.1.1" + d=$TMP_ROOT/relaunched + out=$(run_crew_state "$d" refresh) + assert_contains "$out" 'source: run-step' "a run from before a relaunch is still this task's run: $out" + pass 'a relaunched task keeps runs from its earlier incarnation' +} + +test_spawn_generation_bounds_a_task_without_a_start_record() { + local now d out + now=$(date +%s) + make_incarnation_case spawn-gen-bound "$(ulid_at $((now - 3600)))" "spawn_gen=s$now.1.1" + d=$TMP_ROOT/spawn-gen-bound + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'source: run-step' "the spawn generation bounds an older record: $out" + assert_contains "$out" 'predates this task' 'the ignored run is named' + pass 'the spawn generation bounds attribution when no start record exists' +} + +test_unreadable_run_creation_time_is_not_attributed_to_a_bounded_task() { + local now d out + now=$(date +%s) + make_incarnation_case opaque-id 01RUN "task_started=$now" + d=$TMP_ROOT/opaque-id + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'source: run-step' "an unprovable creation time cannot claim this task's result: $out" + pass 'a run whose creation time is unreadable is not attributed to a bounded task' +} + +test_legacy_route_does_not_inherit_an_earlier_run() { + local now d out + now=$(date +%s) + make_incarnation_case legacy-refresh "$(ulid_at $((now - 3600)))" "task_started=$now" + d=$TMP_ROOT/legacy-refresh + # No overview table: the CLI answers the bare current-branch record. + FM_FAKE_AXI_HOME= + FM_FAKE_RUNS_LIST=" completed fm/refresh $(git -C "$d/wt" rev-parse --short=8 HEAD) $(ledger_minute $((now - 3600))) https://github.com/o/r/pull/1" + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'source: run-step' "the legacy record route must not inherit the earlier run: $out" + assert_contains "$out" 'predates this task' 'the ignored run is named' + pass 'the legacy route does not inherit an earlier task run' +} + +test_coarse_ledger_does_not_inherit_an_earlier_run() { + local now d out short + now=$(date +%s) + make_incarnation_case coarse-refresh 01OTHER "task_started=$now" + d=$TMP_ROOT/coarse-refresh + short=$(git -C "$d/wt" rev-parse --short=8 HEAD) + FM_FAKE_AXI_HOME= + FM_FAKE_AXI_STATUS="$(run_running fm/another)" + FM_FAKE_RUNS_LIST=" completed fm/refresh $short $(ledger_minute $((now - 3600))) https://github.com/o/r/pull/1" + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'source: run-step' "an earlier ledger row must not answer for this task: $out" + FM_FAKE_RUNS_LIST=" completed fm/refresh $short $(ledger_minute "$now") https://github.com/o/r/pull/1" + out=$(run_crew_state "$d" refresh) + assert_not_contains "$out" 'source: run-step' "a row from the task's start minute cannot prove it is this task's: $out" + FM_FAKE_RUNS_LIST=" completed fm/refresh $short $(ledger_minute $((now + 120))) https://github.com/o/r/pull/1" + out=$(run_crew_state "$d" refresh) + assert_contains "$out" 'source: run-step' "a ledger row from this task still answers: $out" + pass 'the coarse ledger does not inherit an earlier task run' +} + +# The start-minute bound applies to the answering row only: the head-anchor row +# proves code identity, so it still anchors from the task's start minute. +test_coarse_ledger_anchor_row_in_start_minute_still_anchors() { + local now d out + now=$(date +%s) + reset_fakes + d=$(new_case coarse-anchor-minute) + make_repo_on_branch "$d/wt" fm/feat-anchor-minute + mint_unfetched_fix_head "$d/wt" >/dev/null + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/anchor-minute.meta" "window=fm:fm-anchor-minute" "worktree=$d/wt" "kind=ship" "task_started=$now" + FM_FAKE_AXI_STATUS="$(run_running fm/other-crew)" + FM_FAKE_RUNS_LIST="$(cat <