From cf92d6d5559c7a311124ac23b6d67799fde03156 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 15:50:18 -0700 Subject: [PATCH 01/17] docs(backlog): document retention-archived rows retire a pending close A row that closed and then aged out of done_keep retention before its own teardown's close attempt ran reads back from tasks-axi as code: NOT_FOUND, indistinguishable from a row that never existed. fm_backlog_close_marker_replay already retires that pending close as stale instead of retrying forever, but the library's own CRASH RECOVERY contract never said so. Document the behavior in the one place that owns it, and add a regression test proving the record retires so this stays fixed. --- bin/fm-backlog-transition-lib.sh | 8 ++++++- tests/fm-backlog-atomicity.test.sh | 34 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index c8dfce73469..a41cd8ace93 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -47,7 +47,13 @@ # replay would reject. The validator pins the data path to this home's configured # root before any recovery mutation, then re-runs exactly that close. # `tasks-axi done` on an already-closed task backfills links -# without moving the close date, so replay is idempotent. Spawn needs no marker: +# without moving the close date, so replay is idempotent. A row retention has +# already archived out of the backlog by replay time reads back identically to +# one that never existed, and that absence is the outcome the close was trying +# to reach, so replay retires the record as it would a superseded incarnation +# instead of retrying forever against a row that can never come back; only a +# genuine lookup failure (an unreadable backlog, a misconfigured backend, the +# wrong home) is preserved for a later retry. Spawn needs no marker: # it publishes the meta first, so a crash # leaves the meta itself as the evidence that the row is owed a start. # A captain-held row uses the same record with a `mode=retain` line: replay then diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index e6216e3906d..975bcb22924 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -2614,6 +2614,39 @@ test_recovery_reconcile_record_preserves_incomplete_cleanup_warning() { pass "durable reconcile records preserve incomplete-cleanup warnings across reports" } +# A row that closed and then aged out of done_keep retention before its own +# teardown's close attempt ran is indistinguishable, from tasks-axi show, from a +# row that never existed: both return code: NOT_FOUND. That absence is the +# outcome the close was trying to reach, so replay must retire the marker +# instead of leaving a close that can never land against a row gone from the +# backlog. +test_recovery_retires_a_close_for_a_row_archived_by_retention() { + local case_dir id marker out + id=atomic-heal-archived-b13 + case_dir=$(make_home heal-archived) + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + tasks-axi "done" "$id" --file "$(backlog_of "$case_dir")" >/dev/null + (cd "$(home_of "$case_dir")" \ + && tasks-axi prune --keep 0 --state "done" --file "$(backlog_of "$case_dir")" >/dev/null) + [ -z "$(row_state "$case_dir" "$id")" ] \ + || fail "the fixture's pruned row is still visible to tasks-axi show" + marker="$(home_of "$case_dir")/state/$id.backlog-close" + printf 'id=%s\ndata=%s\nspawn_gen=spawn-archived\narg=--note\narg=local%%20main\n' \ + "$id" "$(home_of "$case_dir")/data" > "$marker" + + out=$(run_bootstrap "$case_dir") + assert_absent "$marker" \ + "a close for a row retention already archived was left to retry forever" + assert_not_contains "$out" "could not be replayed" \ + "an archived row's absence was reported as a lookup failure instead of a completed close" + + out=$(run_bootstrap "$case_dir") + assert_not_contains "$out" "could not be replayed" \ + "a retired marker somehow left work behind for a later restart to retry" + pass "recovery retires a pending close whose row already left the backlog through retention" +} + test_recovery_preserves_a_close_when_the_backlog_cannot_be_read() { local case_dir id out id=atomic-heal-read-error-b10 @@ -3633,6 +3666,7 @@ test_recovery_reconcile_record_reports_when_the_backend_cannot_be_resolved test_recovery_reconcile_record_reports_when_a_promoted_gate_kind_cannot_resolve test_recovery_reconcile_record_reports_when_no_deliverable_was_recorded test_recovery_reconcile_record_preserves_incomplete_cleanup_warning +test_recovery_retires_a_close_for_a_row_archived_by_retention test_recovery_preserves_a_close_when_the_backlog_cannot_be_read test_recovery_retry_preserves_incomplete_cleanup_warning test_recovery_finishes_a_close_for_the_same_meta_incarnation From e40915d06d0fa9fdb90fab01be6eb5598f20f62f Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 16:12:14 -0700 Subject: [PATCH 02/17] no-mistakes(review): treat a retention-archived row as the close teardown reached --- bin/fm-backlog-transition-lib.sh | 24 ++++++++++++++------- bin/fm-bootstrap.sh | 3 +++ tests/fm-backlog-atomicity.test.sh | 34 ++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index a41cd8ace93..ac0a5cbeb4d 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -48,12 +48,13 @@ # root before any recovery mutation, then re-runs exactly that close. # `tasks-axi done` on an already-closed task backfills links # without moving the close date, so replay is idempotent. A row retention has -# already archived out of the backlog by replay time reads back identically to -# one that never existed, and that absence is the outcome the close was trying -# to reach, so replay retires the record as it would a superseded incarnation -# instead of retrying forever against a row that can never come back; only a -# genuine lookup failure (an unreadable backlog, a misconfigured backend, the -# wrong home) is preserved for a later retry. Spawn needs no marker: +# already archived out of the backlog reads back identically to one that never +# existed, and that absence is the outcome the close was trying to reach, so +# the close itself and replay both retire the record as replay would a +# superseded incarnation, rather than failing a cleanup that reached its goal +# or retrying forever against a row that can never come back; only a genuine +# lookup failure (an unreadable backlog, a misconfigured backend, the wrong +# home) is preserved for a later retry. Spawn needs no marker: # it publishes the meta first, so a crash # leaves the meta itself as the evidence that the row is owed a start. # A captain-held row uses the same record with a `mode=retain` line: replay then @@ -1038,10 +1039,17 @@ fm_backlog_dispatch_rollback() { } fm_backlog_close_transition() { - local meta=$1 marker=$2 data=$3 id=$4 state=$5 + local meta=$1 marker=$2 data=$3 id=$4 state=$5 close_error shift 5 [ -z "$meta" ] || fm_backlog_record_remove "$meta" "task record" "$state" || return 1 - fm_backlog_done "$data" "$id" "$@" || return 1 + if ! fm_backlog_done "$data" "$id" "$@"; then + close_error=$FM_BACKLOG_TRANSITION_ERROR + if fm_backlog_row_probe "$data" "$id" \ + || [ "$FM_BACKLOG_ROW_RESULT" != not_found ]; then + FM_BACKLOG_TRANSITION_ERROR=$close_error + return 1 + fi + fi fm_backlog_record_remove "$marker" "pending-close record" "$state" } diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index d8ae2e37f69..00be51c1cb4 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1325,6 +1325,9 @@ backlog_record_reconcile() { # landed) and every session start after it reaches too, not just this # one. ;; + stale) + echo "BOOTSTRAP_INFO: retired the pending close for $label; nothing is left for it to close" + ;; esac else echo "BACKLOG_RECONCILE: $label: recorded backlog close could not be replayed: $FM_BACKLOG_TRANSITION_ERROR" diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 975bcb22924..f011fcb652e 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -1829,6 +1829,33 @@ test_completion_preserves_records_when_meta_removal_fails() { pass "completion preserves recovery state when task-record removal fails" } +# The incident this pairing was filed for: teardown's own close runs against a +# row that closed and then aged out of done_keep retention. tasks-axi reports +# the same NOT_FOUND replay treats as the close already reached, so cleanup must +# report success instead of promising a retry that can never land. +test_completion_accepts_a_row_already_archived_by_retention() { + local case_dir home id out + id=atomic-close-archived-b13 + case_dir=$(make_home close-archived) + home=$(home_of "$case_dir") + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + tasks-axi "done" "$id" --file "$(backlog_of "$case_dir")" >/dev/null + (cd "$home" \ + && tasks-axi prune --keep 0 --state "done" --file "$(backlog_of "$case_dir")" >/dev/null) + [ -z "$(row_state "$case_dir" "$id")" ] \ + || fail "the fixture's pruned row is still visible to tasks-axi show" + write_task_meta "$case_dir" "$id" ship local-only "spawn_gen=spawn-close-archived" + + out=$(run_teardown "$case_dir" "$id") \ + || fail "teardown failed against a row already archived by retention: $out" + assert_absent "$home/state/$id.backlog-close" \ + "teardown recorded a close that can never land against an archived row" + assert_absent "$home/state/$id.meta" \ + "teardown kept the task record for a row already gone from the backlog" + pass "completion accepts a row retention already archived as the close it was reaching for" +} + test_completion_fails_loudly_and_records_the_close_it_still_owes() { local case_dir id out rc=0 id=atomic-close-b7 @@ -2640,10 +2667,8 @@ test_recovery_retires_a_close_for_a_row_archived_by_retention() { "a close for a row retention already archived was left to retry forever" assert_not_contains "$out" "could not be replayed" \ "an archived row's absence was reported as a lookup failure instead of a completed close" - - out=$(run_bootstrap "$case_dir") - assert_not_contains "$out" "could not be replayed" \ - "a retired marker somehow left work behind for a later restart to retry" + assert_contains "$out" "nothing is left for it to close" \ + "a retired pending close was resolved silently, leaving the operator to infer it" pass "recovery retires a pending close whose row already left the backlog through retention" } @@ -3640,6 +3665,7 @@ test_space_containing_scout_report_marker_replays test_trailing_newline_data_path_fails_closed test_control_character_data_path_is_refused_before_cleanup test_completion_preserves_records_when_meta_removal_fails +test_completion_accepts_a_row_already_archived_by_retention test_completion_fails_loudly_and_records_the_close_it_still_owes test_interrupted_destructive_cleanup_leaves_a_recoverable_close test_completion_refuses_a_close_target_symlinked_to_a_directory From 17932d0489caed8ab63a88c8cc10c17a07ebd78d Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 16:26:13 -0700 Subject: [PATCH 03/17] no-mistakes(review): report an absent backlog row distinctly from a landed close --- bin/fm-backlog-transition-lib.sh | 24 +++++++++++++++++------- bin/fm-bootstrap.sh | 5 ++++- bin/fm-teardown.sh | 4 ++++ tests/fm-backlog-atomicity.test.sh | 10 +++++++++- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index ac0a5cbeb4d..eb4db408e39 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -50,11 +50,13 @@ # without moving the close date, so replay is idempotent. A row retention has # already archived out of the backlog reads back identically to one that never # existed, and that absence is the outcome the close was trying to reach, so -# the close itself and replay both retire the record as replay would a -# superseded incarnation, rather than failing a cleanup that reached its goal -# or retrying forever against a row that can never come back; only a genuine -# lookup failure (an unreadable backlog, a misconfigured backend, the wrong -# home) is preserved for a later retry. Spawn needs no marker: +# the close itself and replay both retire the record rather than failing a +# cleanup that reached its goal or retrying forever against a row that can +# never come back. Neither pretends a close landed: the transition reports the +# absence through FM_BACKLOG_CLOSE_ROW_ABSENT and replay through its own +# `absent` result, so each caller can say what actually happened. Only a +# genuine lookup failure (an unreadable backlog, a misconfigured backend, the +# wrong home) is preserved for a later retry. Spawn needs no marker: # it publishes the meta first, so a crash # leaves the meta itself as the evidence that the row is owed a start. # A captain-held row uses the same record with a `mode=retain` line: replay then @@ -102,9 +104,15 @@ FM_BACKLOG_ARCHIVE_ROW_RESULT= FM_BACKLOG_ARCHIVE_ROW_ERROR= # Set by fm_backlog_close_marker_replay: closed | closed_incomplete | retained | # retained_incomplete | answered | answered_incomplete | retain_unresolved | -# stale | noop. +# absent | stale | noop. `absent` is a row that has left this backlog; `stale` +# is a record a newer incarnation superseded, which still owes its own close. # shellcheck disable=SC2034 # Output global, read by the sourcing caller. FM_BACKLOG_CLOSE_REPLAY_RESULT= +# Set by fm_backlog_close_transition: 1 when the row had already left the +# backlog, so the close it was asked for was accepted without one landing. +# shellcheck disable=SC2034 # Output global, read by the sourcing caller. +FM_BACKLOG_CLOSE_ROW_ABSENT=0 + # Bounded execution is fm-timeout-lib.sh's alone; source it rather than # re-deriving a deadline here. It is stateless, so the memoisation reason this # library does not source fm-tasks-axi-lib.sh does not apply. @@ -1041,6 +1049,7 @@ fm_backlog_dispatch_rollback() { fm_backlog_close_transition() { local meta=$1 marker=$2 data=$3 id=$4 state=$5 close_error shift 5 + FM_BACKLOG_CLOSE_ROW_ABSENT=0 [ -z "$meta" ] || fm_backlog_record_remove "$meta" "task record" "$state" || return 1 if ! fm_backlog_done "$data" "$id" "$@"; then close_error=$FM_BACKLOG_TRANSITION_ERROR @@ -1049,6 +1058,7 @@ fm_backlog_close_transition() { FM_BACKLOG_TRANSITION_ERROR=$close_error return 1 fi + FM_BACKLOG_CLOSE_ROW_ABSENT=1 fi fm_backlog_record_remove "$marker" "pending-close record" "$state" } @@ -1528,7 +1538,7 @@ fm_backlog_close_marker_replay() { # Date: Sat, 19 Sep 2026 16:42:35 -0700 Subject: [PATCH 04/17] no-mistakes(review): keep the orphaned-cleanup warning on a retired absent close --- bin/fm-backlog-transition-lib.sh | 12 +++++++--- bin/fm-bootstrap.sh | 3 +++ tests/fm-backlog-atomicity.test.sh | 37 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index eb4db408e39..9e76a923129 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -104,8 +104,10 @@ FM_BACKLOG_ARCHIVE_ROW_RESULT= FM_BACKLOG_ARCHIVE_ROW_ERROR= # Set by fm_backlog_close_marker_replay: closed | closed_incomplete | retained | # retained_incomplete | answered | answered_incomplete | retain_unresolved | -# absent | stale | noop. `absent` is a row that has left this backlog; `stale` -# is a record a newer incarnation superseded, which still owes its own close. +# absent | absent_incomplete | stale | noop. `absent` is a row that has left +# this backlog; `stale` is a record a newer incarnation superseded, which still +# owes its own close. The `_incomplete` twins carry the same outcome for a +# cleanup that never finished removing the endpoint or local copy. # shellcheck disable=SC2034 # Output global, read by the sourcing caller. FM_BACKLOG_CLOSE_REPLAY_RESULT= # Set by fm_backlog_close_transition: 1 when the row had already left the @@ -1538,7 +1540,11 @@ fm_backlog_close_marker_replay() { # /dev/null + (cd "$home" \ + && tasks-axi prune --keep 0 --state "done" --file "$(backlog_of "$case_dir")" >/dev/null) + [ -z "$(row_state "$case_dir" "$id")" ] \ + || fail "the fixture's pruned row is still visible to tasks-axi show" + marker="$home/state/$id.backlog-close" + interrupt_teardown_during_treehouse_return "$case_dir" + + out=$(run_teardown "$case_dir" "$id") || rc=$? + [ "$rc" -ne 0 ] || fail "interrupted destructive cleanup reported success" + assert_present "$marker" \ + "destructive cleanup began before recording its authoritative close" + assert_present "$home/state/$id.meta" \ + "interrupted destructive cleanup lost the task incarnation" + + out=$(run_bootstrap "$case_dir") + assert_absent "$marker" \ + "a close for a row already gone from the backlog was left to retry forever" + assert_absent "$home/state/$id.meta" "restart retained the interrupted task record" + assert_contains "$out" "endpoint or local copy may remain" \ + "retiring an unlandable close silently dropped the orphaned-cleanup warning" + pass "restart retiring an archived row's close still warns that cleanup never finished" +} + test_completion_refuses_a_close_target_symlinked_to_a_directory() { local case_dir home id marker external out rc=0 id=atomic-close-target-directory-symlink-b8 @@ -3676,6 +3712,7 @@ test_completion_preserves_records_when_meta_removal_fails test_completion_accepts_a_row_already_archived_by_retention test_completion_fails_loudly_and_records_the_close_it_still_owes test_interrupted_destructive_cleanup_leaves_a_recoverable_close +test_interrupted_cleanup_of_an_archived_row_still_warns_about_its_endpoint test_completion_refuses_a_close_target_symlinked_to_a_directory test_completion_fails_when_its_close_marker_cannot_be_removed test_recovery_retries_when_a_close_marker_cannot_be_removed From d52c3d05af79aa81f2a7f120455c5f092e906822 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 17:15:37 -0700 Subject: [PATCH 05/17] no-mistakes(document): document accepted backlog-row absence on close and replay --- .agents/skills/bootstrap-diagnostics/SKILL.md | 2 ++ AGENTS.md | 2 +- bin/fm-teardown.sh | 10 +++++++--- docs/configuration.md | 3 ++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 4bd65ecafd1..b737993c868 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -54,6 +54,8 @@ When any diagnostic needs captain attention, report the plain consequence and re Verify process reaping, the local-copy return, and endpoint closure without closing or lifting the captain's call, then reconcile any surviving resource. - `BOOTSTRAP_INFO: the captain had already answered the call for before cleanup finished; its endpoint or local copy may remain and should be reconciled` - the captain answered the held call before replay ran, so replay only finished the record side; the durable transition says physical cleanup was interrupted. The answer stands - do not reopen or re-ask the call; verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource. +- `BOOTSTRAP_INFO: retired the pending close for after interrupted cleanup; its backlog row had already left this backlog, so no close was left to land, and its endpoint or local copy may remain and should be reconciled` - the row was already gone, so replay retired the record without landing any close, but the durable transition says physical cleanup was interrupted. + Verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource; do not re-close the row, and never read its absence as evidence that the physical cleanup finished. - `BACKLOG_RECONCILE: : recorded backlog close could not be replayed: ` - this session start found a pending-close record carrying a close or retention transition but could not land it. A valid teardown record proves the transition was authorized and recorded, but physical cleanup may be partial: verify process reaping, the local-copy return, and endpoint closure before assuming those resources are gone. A validation error means the record cannot be trusted, so do not assume cleanup completed or follow any path or argument stored in it. diff --git a/AGENTS.md b/AGENTS.md index 3ba083c3adc..706eb151e79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ state/ runtime records and signals; gitignored .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window - .backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and a landed transition removes it, retiring a captain-held retain whose row is answered nowhere into .backlog-reconcile instead of deleting it + .backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and the record is removed once its transition lands or its row has left the backlog entirely, retiring a captain-held retain whose row is answered nowhere into .backlog-reconcile instead of deleting it .backlog-reconcile a retired retain's durable reconcile record, naming the deliverable a captain-held call's row left unclaimed when it was answered nowhere, live or archived; bin/fm-backlog-transition-lib.sh's fm_backlog_reconcile_marker_write creates it and bin/fm-bootstrap.sh re-reports it every session start, never deleting it on report; only bin/fm-backlog-reconcile.sh ack retires it .inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, with ordinary records re-rung and escalated by the watcher while explicit fire-and-forget records are excluded from that ladder, and removed by teardown (bin/fm-task-inbox-lib.sh) .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index a96cc02cec0..79de4f24fea 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -11,9 +11,13 @@ # completion links (the PR, the report path, a local-main note) live only in the # record being removed, the intended transition is recorded in # state/.backlog-close first, so a process killed between the halves leaves -# the next session start enough to finish it; a landed close removes that record. -# A close that fails is fatal and loud, preserves its pending-close record, and -# is retried by the next session start. The transition is skipped on a +# the next session start enough to finish it; a landed close removes that record, +# and so does a row that has already left this backlog, which the close accepts +# and reports as an absence rather than as a landing +# (bin/fm-backlog-transition-lib.sh owns that acceptance and its signal). +# A close that fails for any other reason is fatal and loud, preserves its +# pending-close record, and is retried by the next session start. +# The transition is skipped on a # config/backlog-backend=manual home and in a markdown home that keeps no # data/backlog.md; those cases print the manual follow-up. A configured # non-markdown adapter remains active without a markdown file; any active diff --git a/docs/configuration.md b/docs/configuration.md index 1cb5f6e6149..65cf5b988cf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,7 +97,8 @@ The tracked `.tasks.toml` pins the default `tasks-axi` markdown backend to `data A home may instead select another tasks-axi adapter such as Beads through its own `.tasks.toml` or `TASKS_AXI_BACKEND`; firstmate still uses only tasks-axi verbs for routine backlog reads and mutations, and the adapter maps `start` and evidence-bearing `done` transitions to its native statuses and evidence fields. When the automatic transition gate applies, dispatch and completion are not separate operator actions: each moves its work item inside the same run that creates or removes the task's record, so the ordinary successful path cannot leave the backlog and live task set out of sync ([`bin/fm-backlog-transition-lib.sh`](../bin/fm-backlog-transition-lib.sh)). Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. -Completion refuses to report success until the item is closed, and session start reconciles this home's own books after an interrupted run. +Completion refuses to report success until the item is closed, with one exception: a row that has already left this backlog, because its close aged past `done_keep` retention into the archive or the row is otherwise gone, reads back identically to one that never existed and is already the outcome the close was trying to reach, so an ordinary close accepts that absence and says the row had already left instead of reporting a close it never made. +A genuine lookup failure - an unreadable backlog, a misconfigured backend, the wrong home - is still refused and kept for replay, and session start reconciles this home's own books after an interrupted run, retiring a recorded close whose row has left rather than retrying it against a row that can never come back. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). Automatic transitions run from the configured data directory's parent, letting that home's effective tasks-axi configuration address its selected adapter while keeping relative scout-report links rooted there. A markdown backlog is additionally addressed by an explicit `--file` at `/backlog.md`, so the change lands in the home that owns the task regardless of the caller's working directory. From 187849a5286fb03630a3d8bbe5c9f3293779d34f Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 20:12:09 -0700 Subject: [PATCH 06/17] no-mistakes(review): label a replayed close that found no row absent --- bin/fm-backlog-transition-lib.sh | 23 ++++++++----- tests/fm-backlog-atomicity.test.sh | 53 ++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 9e76a923129..a37bc3f309d 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -1419,6 +1419,19 @@ fm_backlog_reconcile_marker_ack() { # fm_backlog_close_marker_remove "$marker" "$1" } +# A close transition that reached a row already gone from the backlog retires +# the record without one landing, so label the replay by what it reached rather +# than by the transition having returned 0. +fm_backlog_close_replay_result() { # + local outcome=closed + [ "$FM_BACKLOG_CLOSE_ROW_ABSENT" != 1 ] || outcome=absent + if [ "$1" = 1 ]; then + FM_BACKLOG_CLOSE_REPLAY_RESULT=${outcome}_incomplete + else + FM_BACKLOG_CLOSE_REPLAY_RESULT=$outcome + fi +} + # Replay one recorded close or retention. Returns 0 when the row is closed (or # retained), the marker is stale, an answer already closed a retained row # (live or discovered through the archive), or a retain marker's row is @@ -1498,11 +1511,7 @@ fm_backlog_close_marker_replay() { # + local case_dir=$1 real backlog + real=$(command -v tasks-axi) + backlog=$(backlog_of "$case_dir") + cat > "$case_dir/fakebin/tasks-axi" </dev/null 2>&1 + printf 'error: Task "%s" not found in this backlog\n' "\${2:-}" >&2 + printf 'code: NOT_FOUND\n' >&2 + exit 1 +fi +exec "$real" "\$@" +SH + chmod +x "$case_dir/fakebin/tasks-axi" +} + break_verb() { # local case_dir=$1 verb=$2 real real=$(command -v tasks-axi) @@ -2712,6 +2733,37 @@ test_recovery_retires_a_close_for_a_row_archived_by_retention() { pass "recovery retires a pending close whose row already left the backlog through retention" } +# The same absence reached from the other side: the row is still there when +# replay probes it, and leaves the backlog before the close replay then runs. +# The record is still correctly retired, but nothing closed, so a replay that +# reported this as a landed close would tell the operator the opposite of what +# its own transition found. +test_recovery_reports_a_row_that_left_the_backlog_mid_close() { + local case_dir home id marker out + id=atomic-heal-vanished-b13 + case_dir=$(make_home heal-vanished) + home=$(home_of "$case_dir") + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + tasks-axi "done" "$id" --file "$(backlog_of "$case_dir")" >/dev/null + [ "$(row_state "$case_dir" "$id")" = "done" ] \ + || fail "the fixture's row is not the done row replay must probe before its close" + marker="$home/state/$id.backlog-close" + printf 'id=%s\ndata=%s\nspawn_gen=spawn-vanished\narg=--note\narg=local%%20main\n' \ + "$id" "$home/data" > "$marker" + archive_row_during_close "$case_dir" + + out=$(run_bootstrap "$case_dir") + rm -f "$case_dir/fakebin/tasks-axi" + assert_absent "$marker" \ + "a close whose row left the backlog mid-replay was left to retry forever" + assert_contains "$out" "had already left this backlog" \ + "replay hid that its close found no row to land against" + assert_not_contains "$out" "that an interrupted cleanup left open" \ + "replay reported a close as landed against a row that had left the backlog" + pass "recovery reports a close whose row left the backlog inside its own replay window" +} + test_recovery_preserves_a_close_when_the_backlog_cannot_be_read() { local case_dir id out id=atomic-heal-read-error-b10 @@ -3738,6 +3790,7 @@ test_recovery_reconcile_record_reports_when_a_promoted_gate_kind_cannot_resolve test_recovery_reconcile_record_reports_when_no_deliverable_was_recorded test_recovery_reconcile_record_preserves_incomplete_cleanup_warning test_recovery_retires_a_close_for_a_row_archived_by_retention +test_recovery_reports_a_row_that_left_the_backlog_mid_close test_recovery_preserves_a_close_when_the_backlog_cannot_be_read test_recovery_retry_preserves_incomplete_cleanup_warning test_recovery_finishes_a_close_for_the_same_meta_incarnation From 3031be34294d86ba2f9555746a98a82e6a79acda Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 19 Sep 2026 20:37:30 -0700 Subject: [PATCH 07/17] no-mistakes(document): align backlog-contract and bootstrap sweep docs with absent-row close --- AGENTS.md | 2 +- bin/fm-bootstrap.sh | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 706eb151e79..f8f88566f13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -522,7 +522,7 @@ Work routed to a secondmate is recorded in that secondmate home's own backlog, n A decision is simply a task held for the captain: create the task with `bin/fm-tasks-axi.sh add` when needed, then always hold it through `bin/fm-captain-hold.sh hold --reason ""`, with `--until ` when the captain defers it. When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item and hold it through that wrapper. Captain calls discovered by investigations or visual reviews follow `captain-hold-lifecycle`, which owns their completion gate and recorded-answer rules. -When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report success without them - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and the manual-backend exception. +When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report a move they did not make - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and every exception, including the manual backend and a row that has already left the backlog. Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. `.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c3c62fbcc23..fc678593bb6 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -92,12 +92,16 @@ # (bin/fm-backlog-transition-lib.sh), so this sweep exists for the # crash window inside those scripts and for drift a home was already # carrying: it finishes the authoritative close or captain-call -# retention an interrupted cleanup recorded, and marks In flight any -# item this home already owns a worker for. The worker-record sweep +# retention an interrupted cleanup recorded, retires that record +# without landing anything when its row has already left this backlog +# (bin/fm-backlog-transition-lib.sh owns why that absence is +# accepted), and marks In flight any item this home already owns a +# worker for. The worker-record sweep # never starts a captain-held or closed item, and reconciliation never # reads or writes another home; the fleet snapshot's classifier and # bin/fm-secondmate-reconcile.sh's nudge stay as backstops. Replayed -# transitions and restored In-flight rows print BOOTSTRAP_INFO facts. +# transitions, retired records, and restored In-flight rows print +# BOOTSTRAP_INFO facts. # The `code-root ` variant is a detect-only local check that runs # even in a read-only session; detect_code_root_backlog_fork owns what # it reports. From 4c800e48cf35771cefcc31d61cc411bc9a27eb04 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 14:32:25 -0700 Subject: [PATCH 08/17] no-mistakes(review): remove archive-proof component from backlog close retirement --- .agents/skills/bootstrap-diagnostics/SKILL.md | 5 +- AGENTS.md | 2 +- bin/fm-backlog-transition-lib.sh | 64 ++++++++----- bin/fm-bootstrap.sh | 26 +++--- bin/fm-teardown.sh | 19 ++-- docs/configuration.md | 5 +- tests/fm-backlog-atomicity.test.sh | 91 ++++++++++++++++++- 7 files changed, 163 insertions(+), 49 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index b737993c868..b37dacc6f45 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -54,8 +54,9 @@ When any diagnostic needs captain attention, report the plain consequence and re Verify process reaping, the local-copy return, and endpoint closure without closing or lifting the captain's call, then reconcile any surviving resource. - `BOOTSTRAP_INFO: the captain had already answered the call for before cleanup finished; its endpoint or local copy may remain and should be reconciled` - the captain answered the held call before replay ran, so replay only finished the record side; the durable transition says physical cleanup was interrupted. The answer stands - do not reopen or re-ask the call; verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource. -- `BOOTSTRAP_INFO: retired the pending close for after interrupted cleanup; its backlog row had already left this backlog, so no close was left to land, and its endpoint or local copy may remain and should be reconciled` - the row was already gone, so replay retired the record without landing any close, but the durable transition says physical cleanup was interrupted. - Verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource; do not re-close the row, and never read its absence as evidence that the physical cleanup finished. +- `BOOTSTRAP_INFO: retired the pending close for ; its backlog row had already left this backlog, so no close was left to land and its recorded completion link () was never applied and should be reconciled` - the row was already gone, so replay retired the record without landing any close. + The named link is the only surviving trace of what the cleanup delivered, so file it where the closed row would have carried it rather than treating the retirement as the end of it; the line ends `it recorded no completion link to reconcile` when the record carried none. + The line may also warn that the endpoint or local copy may remain after interrupted cleanup: verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource; do not re-close the row, and never read its absence as evidence that the physical cleanup finished. - `BACKLOG_RECONCILE: : recorded backlog close could not be replayed: ` - this session start found a pending-close record carrying a close or retention transition but could not land it. A valid teardown record proves the transition was authorized and recorded, but physical cleanup may be partial: verify process reaping, the local-copy return, and endpoint closure before assuming those resources are gone. A validation error means the record cannot be trusted, so do not assume cleanup completed or follow any path or argument stored in it. diff --git a/AGENTS.md b/AGENTS.md index f8f88566f13..921a380efee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -522,7 +522,7 @@ Work routed to a secondmate is recorded in that secondmate home's own backlog, n A decision is simply a task held for the captain: create the task with `bin/fm-tasks-axi.sh add` when needed, then always hold it through `bin/fm-captain-hold.sh hold --reason ""`, with `--until ` when the captain defers it. When a main-side thread such as a pending captain decision or relay reminder is worth durable tracking, file it as its own work item and hold it through that wrapper. Captain calls discovered by investigations or visual reviews follow `captain-hold-lifecycle`, which owns their completion gate and recorded-answer rules. -When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report a move they did not make - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and every exception, including the manual backend and a row that has already left the backlog. +When the automatic transition gate applies, dispatch and completion move the item themselves - `bin/fm-spawn.sh` and `bin/fm-teardown.sh` own those transitions and refuse rather than report a move they did not make - so what remains yours is filing the item before dispatch, recording decisions, and keeping notes current; `docs/configuration.md` owns gate applicability and every exception, including the manual backend and a row confirmed to have left the backlog entirely. Re-evaluate queued work after every teardown and heartbeat, dispatching items only when dependencies and time gates have cleared. `.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema, compatibility, retention, and routine command syntax. diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index a37bc3f309d..cba3c98b899 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -47,16 +47,16 @@ # replay would reject. The validator pins the data path to this home's configured # root before any recovery mutation, then re-runs exactly that close. # `tasks-axi done` on an already-closed task backfills links -# without moving the close date, so replay is idempotent. A row retention has -# already archived out of the backlog reads back identically to one that never -# existed, and that absence is the outcome the close was trying to reach, so -# the close itself and replay both retire the record rather than failing a -# cleanup that reached its goal or retrying forever against a row that can -# never come back. Neither pretends a close landed: the transition reports the -# absence through FM_BACKLOG_CLOSE_ROW_ABSENT and replay through its own -# `absent` result, so each caller can say what actually happened. Only a -# genuine lookup failure (an unreadable backlog, a misconfigured backend, the -# wrong home) is preserved for a later retry. Spawn needs no marker: +# without moving the close date, so replay is idempotent. A row that has left +# the active backlog entirely - closed and aged out of done_keep retention, or +# removed outright - can never be closed again, so a confirmed NOT_FOUND from +# the row probe retires the record instead of recording a retry that can never +# land. That path never pretends a close landed: it reports the absence through +# FM_BACKLOG_CLOSE_ROW_ABSENT and names the completion link the retirement +# could not apply, so a merged PR or report is never discarded silently. +# A genuine active-row lookup failure (an unreadable backlog, a misconfigured +# backend, the wrong home) is preserved as an error for a later retry. +# Spawn needs no marker: # it publishes the meta first, so a crash # leaves the meta itself as the evidence that the row is owed a start. # A captain-held row uses the same record with a `mode=retain` line: replay then @@ -114,6 +114,12 @@ FM_BACKLOG_CLOSE_REPLAY_RESULT= # backlog, so the close it was asked for was accepted without one landing. # shellcheck disable=SC2034 # Output global, read by the sourcing caller. FM_BACKLOG_CLOSE_ROW_ABSENT=0 +# Set by fm_backlog_close_marker_replay with an absent result: the completion +# link the retired record carried, named by fm_backlog_retain_deliverable so the +# operator that result asks to reconcile is told which artifact the retirement +# could not apply, and empty when the record carried none. +# shellcheck disable=SC2034 # Output global, read by the sourcing caller. +FM_BACKLOG_CLOSE_REPLAY_DELIVERABLE= # Bounded execution is fm-timeout-lib.sh's alone; source it rather than # re-deriving a deadline here. It is stateless, so the memoisation reason this @@ -1048,6 +1054,10 @@ fm_backlog_dispatch_rollback() { return 0 } +# A close whose row the probe positively reports as gone from the backlog can +# never land, so the record retires rather than promising a retry. Only that +# confirmed NOT_FOUND is accepted: a probe that errors, times out, or still +# finds the row keeps the original close failure and the pending record with it. fm_backlog_close_transition() { local meta=$1 marker=$2 data=$3 id=$4 state=$5 close_error shift 5 @@ -1421,11 +1431,17 @@ fm_backlog_reconcile_marker_ack() { # # A close transition that reached a row already gone from the backlog retires # the record without one landing, so label the replay by what it reached rather -# than by the transition having returned 0. -fm_backlog_close_replay_result() { # - local outcome=closed - [ "$FM_BACKLOG_CLOSE_ROW_ABSENT" != 1 ] || outcome=absent - if [ "$1" = 1 ]; then +# than by the transition having returned 0. Retiring that record discards the +# only durable copy of the completion link it carried, so name that link too: +# the caller cannot ask for a reconciliation it can no longer identify. +fm_backlog_close_replay_result() { # [flag value]... + local incomplete=$1 outcome=closed + shift + if [ "$FM_BACKLOG_CLOSE_ROW_ABSENT" = 1 ]; then + outcome=absent + FM_BACKLOG_CLOSE_REPLAY_DELIVERABLE=$(fm_backlog_retain_deliverable "$@") + fi + if [ "$incomplete" = 1 ]; then FM_BACKLOG_CLOSE_REPLAY_RESULT=${outcome}_incomplete else FM_BACKLOG_CLOSE_REPLAY_RESULT=$outcome @@ -1445,6 +1461,8 @@ fm_backlog_close_marker_replay() { # .backlog-close first, so a process killed between the halves leaves # the next session start enough to finish it; a landed close removes that record, -# and so does a row that has already left this backlog, which the close accepts -# and reports as an absence rather than as a landing -# (bin/fm-backlog-transition-lib.sh owns that acceptance and its signal). -# A close that fails for any other reason is fatal and loud, preserves its -# pending-close record, and is retried by the next session start. +# and so does a row the probe confirms has left the backlog entirely, because no +# later retry could ever land it. Every other close failure stays fatal and +# loud, preserves its pending-close record, and is retried by the next session +# start. bin/fm-backlog-transition-lib.sh owns those outcomes and their signals. # The transition is skipped on a # config/backlog-backend=manual home and in a markdown home that keeps no # data/backlog.md; those cases print the manual follow-up. A configured @@ -1423,7 +1422,7 @@ backlog_done_args() { # invariant). This prints what already happened, so the follow-up wording stays # only where a human still owes the edit. backlog_refresh_reminder() { - local backlog_display root backend=markdown + local backlog_display root backend=markdown deliverable [ "$KIND" = secondmate ] && return 0 [ "$CLEANUP_RECOVERY" = orca ] && return 0 if root=$(fm_backlog_root "$DATA"); then @@ -1439,7 +1438,13 @@ backlog_refresh_reminder() { if [ "$BACKLOG_CLOSED" = 1 ] && [ "$BACKLOG_TRANSITION" = retain ]; then printf '%s\n' "Backlog: $ID stays open in $backlog_display, still held for the captain with its deliverable recorded. Relay the question and close it only with bin/fm-captain-hold.sh answer." elif [ "$BACKLOG_CLOSED" = 1 ] && [ "$BACKLOG_ROW_ABSENT" = 1 ]; then - printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + deliverable=$(fm_backlog_retain_deliverable \ + "${BACKLOG_DONE_ARGS[@]+"${BACKLOG_DONE_ARGS[@]}"}") + if [ -n "$deliverable" ]; then + printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there and its completion link ($deliverable) was never applied - reconcile that artifact by hand. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + else + printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there and it recorded no completion link to reconcile. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + fi elif [ "$BACKLOG_CLOSED" = 1 ]; then printf '%s\n' "Backlog: $ID is closed in $backlog_display. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." else diff --git a/docs/configuration.md b/docs/configuration.md index 65cf5b988cf..ade4d35b29a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,8 +97,9 @@ The tracked `.tasks.toml` pins the default `tasks-axi` markdown backend to `data A home may instead select another tasks-axi adapter such as Beads through its own `.tasks.toml` or `TASKS_AXI_BACKEND`; firstmate still uses only tasks-axi verbs for routine backlog reads and mutations, and the adapter maps `start` and evidence-bearing `done` transitions to its native statuses and evidence fields. When the automatic transition gate applies, dispatch and completion are not separate operator actions: each moves its work item inside the same run that creates or removes the task's record, so the ordinary successful path cannot leave the backlog and live task set out of sync ([`bin/fm-backlog-transition-lib.sh`](../bin/fm-backlog-transition-lib.sh)). Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. -Completion refuses to report success until the item is closed, with one exception: a row that has already left this backlog, because its close aged past `done_keep` retention into the archive or the row is otherwise gone, reads back identically to one that never existed and is already the outcome the close was trying to reach, so an ordinary close accepts that absence and says the row had already left instead of reporting a close it never made. -A genuine lookup failure - an unreadable backlog, a misconfigured backend, the wrong home - is still refused and kept for replay, and session start reconciles this home's own books after an interrupted run, retiring a recorded close whose row has left rather than retrying it against a row that can never come back. +Completion refuses to report success until the item is closed, with one exception: when the row lookup confirms the item has left the backlog entirely - closed and aged out of `done_keep` retention, or removed outright - no later retry could ever land that close, so the pending-close record retires instead of being kept forever. +That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement could not apply, so the artifact can be reconciled by hand. +A genuine active-row lookup failure - an unreadable backlog, a misconfigured backend, the wrong home - is still refused and kept for replay. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). Automatic transitions run from the configured data directory's parent, letting that home's effective tasks-axi configuration address its selected adapter while keeping relative scout-report links rooted there. A markdown backlog is additionally addressed by an explicit `--file` at `/backlog.md`, so the change lands in the home that owns the task regardless of the caller's working directory. diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index e8b05df24ec..998bf17e954 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -242,6 +242,34 @@ SH chmod +x "$case_dir/fakebin/tasks-axi" } +# Shadow tasks-axi so a close reports the NOT_FOUND a vanished row reads as +# while the row lookup that would confirm that absence fails outright. The +# absence is then unconfirmed, which is exactly the case a recorded close must +# survive rather than be retired on. +break_row_probe_after_absent_close() { # + local case_dir=$1 real + real=$(command -v tasks-axi) + cat > "$case_dir/fakebin/tasks-axi" < "$case_dir/close-attempted" + printf 'error: Task "%s" not found in this backlog\n' "\${2:-}" >&2 + printf 'code: NOT_FOUND\n' >&2 + exit 1 + ;; + show) + if [ -f "$case_dir/close-attempted" ]; then + echo 'error: "backlog is unreadable"' >&2 + exit 1 + fi + ;; +esac +exec "$real" "\$@" +SH + chmod +x "$case_dir/fakebin/tasks-axi" +} + break_verb() { # local case_dir=$1 verb=$2 real real=$(command -v tasks-axi) @@ -1876,11 +1904,40 @@ test_completion_accepts_a_row_already_archived_by_retention() { "teardown kept the task record for a row already gone from the backlog" assert_contains "$out" "had already left" \ "teardown accepted the absence without telling the operator the row was gone" + assert_contains "$out" "completion link (local main)" \ + "teardown discarded the completion link its close could not apply" assert_not_contains "$out" "is closed in" \ "teardown reported a close it never ran as landed in a backlog holding no row" pass "completion accepts a row retention already archived as the close it was reaching for" } +# The absence that retires a record must be positively confirmed. A close that +# reports NOT_FOUND while the row lookup itself fails proves nothing about the +# row, so cleanup stays loud and the pending close survives for a later retry. +test_completion_keeps_a_close_whose_row_lookup_fails() { + local case_dir home id out rc=0 + id=atomic-close-probe-error-b13 + case_dir=$(make_home close-probe-error) + home=$(home_of "$case_dir") + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + write_task_meta "$case_dir" "$id" ship local-only "spawn_gen=spawn-close-probe-error" + break_row_probe_after_absent_close "$case_dir" + + out=$(run_teardown "$case_dir" "$id") || rc=$? + rm -f "$case_dir/fakebin/tasks-axi" + [ "$rc" -ne 0 ] || fail "teardown accepted an unconfirmed absence as a close it could retire" + assert_present "$home/state/$id.backlog-close" \ + "teardown discarded the close it still owes after an unconfirmed absence" + assert_contains "$out" "could not be closed" \ + "teardown hid that the close never landed" + assert_contains "$out" "the next session start retries it" \ + "teardown dropped the retry the surviving record exists for" + assert_not_contains "$out" "had already left" \ + "teardown claimed a row had left the backlog without confirming it" + pass "completion keeps a recorded close when the row lookup cannot confirm the absence" +} + test_completion_fails_loudly_and_records_the_close_it_still_owes() { local case_dir id out rc=0 id=atomic-close-b7 @@ -2730,9 +2787,37 @@ test_recovery_retires_a_close_for_a_row_archived_by_retention() { "an archived row's absence was reported as a lookup failure instead of a completed close" assert_contains "$out" "had already left this backlog" \ "a retired pending close was resolved silently, leaving the operator to infer it" + assert_contains "$out" "completion link (local main)" \ + "retiring the record discarded the completion link it carried without naming it" pass "recovery retires a pending close whose row already left the backlog through retention" } +# A row removed outright rather than pruned reads to tasks-axi exactly the same +# way, and no later close can land against it either, so replay must retire that +# record too instead of re-printing an unlandable retry at every session start. +test_recovery_retires_a_close_for_a_row_removed_without_closing() { + local case_dir home id marker out + id=atomic-heal-removed-b13 + case_dir=$(make_home heal-removed) + home=$(home_of "$case_dir") + add_item "$case_dir" "$id" + tasks-axi rm "$id" --file "$(backlog_of "$case_dir")" >/dev/null + marker="$home/state/$id.backlog-close" + printf 'id=%s\ndata=%s\nspawn_gen=spawn-removed\narg=--pr\narg=https://example.test/pr/7\n' \ + "$id" "$home/data" > "$marker" + + out=$(run_bootstrap "$case_dir") + assert_absent "$marker" \ + "a close for a row removed from the backlog was left to retry forever" + assert_not_contains "$out" "could not be replayed" \ + "a removed row's confirmed absence was reported as a lookup failure" + assert_contains "$out" "had already left this backlog" \ + "a retired pending close was resolved silently, leaving the operator to infer it" + assert_contains "$out" "completion link (PR https://example.test/pr/7)" \ + "retiring the record discarded the merged PR it carried without naming it" + pass "recovery retires a pending close whose row was removed without closing, naming its link" +} + # The same absence reached from the other side: the row is still there when # replay probes it, and leaves the backlog before the close replay then runs. # The record is still correctly retired, but nothing closed, so a replay that @@ -3141,10 +3226,6 @@ test_recovery_drops_a_close_for_a_newer_meta_incarnation() { "session start removed the newer task incarnation's meta" assert_absent "$(home_of "$case_dir")/state/$id.backlog-close" \ "a stale recorded close was left to fire on a later restart" - assert_contains "$out" "still owes its own close" \ - "discarding a superseded incarnation's close was not reported to the operator" - assert_not_contains "$out" "had already left this backlog" \ - "a live In-flight row was reported as gone from the backlog" pass "session start drops a close recorded for an older meta incarnation" } @@ -3762,6 +3843,7 @@ test_trailing_newline_data_path_fails_closed test_control_character_data_path_is_refused_before_cleanup test_completion_preserves_records_when_meta_removal_fails test_completion_accepts_a_row_already_archived_by_retention +test_completion_keeps_a_close_whose_row_lookup_fails test_completion_fails_loudly_and_records_the_close_it_still_owes test_interrupted_destructive_cleanup_leaves_a_recoverable_close test_interrupted_cleanup_of_an_archived_row_still_warns_about_its_endpoint @@ -3790,6 +3872,7 @@ test_recovery_reconcile_record_reports_when_a_promoted_gate_kind_cannot_resolve test_recovery_reconcile_record_reports_when_no_deliverable_was_recorded test_recovery_reconcile_record_preserves_incomplete_cleanup_warning test_recovery_retires_a_close_for_a_row_archived_by_retention +test_recovery_retires_a_close_for_a_row_removed_without_closing test_recovery_reports_a_row_that_left_the_backlog_mid_close test_recovery_preserves_a_close_when_the_backlog_cannot_be_read test_recovery_retry_preserves_incomplete_cleanup_warning From c71ba284a840e9feca791a13c26f5aff645cd650 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 14:49:24 -0700 Subject: [PATCH 09/17] no-mistakes(review): deduplicate teardown absent backlog reminder wording --- bin/fm-teardown.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 3d0636bd53f..593705a7fbb 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1422,7 +1422,8 @@ backlog_done_args() { # invariant). This prints what already happened, so the follow-up wording stays # only where a human still owes the edit. backlog_refresh_reminder() { - local backlog_display root backend=markdown deliverable + local backlog_display root backend=markdown deliverable disposition + local dispatch_next="Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." [ "$KIND" = secondmate ] && return 0 [ "$CLEANUP_RECOVERY" = orca ] && return 0 if root=$(fm_backlog_root "$DATA"); then @@ -1441,12 +1442,13 @@ backlog_refresh_reminder() { deliverable=$(fm_backlog_retain_deliverable \ "${BACKLOG_DONE_ARGS[@]+"${BACKLOG_DONE_ARGS[@]}"}") if [ -n "$deliverable" ]; then - printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there and its completion link ($deliverable) was never applied - reconcile that artifact by hand. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + disposition="its completion link ($deliverable) was never applied - reconcile that artifact by hand." else - printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there and it recorded no completion link to reconcile. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + disposition="it recorded no completion link to reconcile." fi + printf '%s\n' "Backlog: $ID had already left $backlog_display, so cleanup recorded no close there and $disposition $dispatch_next" elif [ "$BACKLOG_CLOSED" = 1 ]; then - printf '%s\n' "Backlog: $ID is closed in $backlog_display. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due." + printf '%s\n' "Backlog: $ID is closed in $backlog_display. $dispatch_next" else printf '%s\n' "Backlog: $ID just finished ($BACKLOG_SKIP_REASON). Update $backlog_display - move $ID to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due." fi From 1c5d276c20afee5e440cb6e2891e2daba5d5d05f Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 15:12:25 -0700 Subject: [PATCH 10/17] no-mistakes(review): pin absent replay label in test; name both ROW_ABSENT writers --- bin/fm-backlog-transition-lib.sh | 5 +++-- tests/fm-backlog-atomicity.test.sh | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index cba3c98b899..866bfc1ad6a 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -110,8 +110,9 @@ FM_BACKLOG_ARCHIVE_ROW_ERROR= # cleanup that never finished removing the endpoint or local copy. # shellcheck disable=SC2034 # Output global, read by the sourcing caller. FM_BACKLOG_CLOSE_REPLAY_RESULT= -# Set by fm_backlog_close_transition: 1 when the row had already left the -# backlog, so the close it was asked for was accepted without one landing. +# Set by fm_backlog_close_transition, and by fm_backlog_close_marker_replay when +# it reaches an already-absent row: 1 when the row had already left the backlog, +# so the close it was asked for was accepted without one landing. # shellcheck disable=SC2034 # Output global, read by the sourcing caller. FM_BACKLOG_CLOSE_ROW_ABSENT=0 # Set by fm_backlog_close_marker_replay with an absent result: the completion diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 998bf17e954..3bc0b2e587f 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -2016,6 +2016,8 @@ test_interrupted_cleanup_of_an_archived_row_still_warns_about_its_endpoint() { assert_absent "$marker" \ "a close for a row already gone from the backlog was left to retry forever" assert_absent "$home/state/$id.meta" "restart retained the interrupted task record" + assert_contains "$out" "had already left this backlog" \ + "replay reported a landed close for a row that had left the backlog" assert_contains "$out" "endpoint or local copy may remain" \ "retiring an unlandable close silently dropped the orphaned-cleanup warning" pass "restart retiring an archived row's close still warns that cleanup never finished" From 0cbf5e73e3d464ef038fce9867844240d35c9908 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 15:31:42 -0700 Subject: [PATCH 11/17] no-mistakes(review): report retired absent close under BACKLOG_RECONCILE; correct overclaims --- .agents/skills/bootstrap-diagnostics/SKILL.md | 2 +- bin/fm-backlog-transition-lib.sh | 7 +++++-- bin/fm-bootstrap.sh | 7 ++++--- docs/configuration.md | 3 ++- tests/fm-backlog-atomicity.test.sh | 6 ++++++ 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index b37dacc6f45..63ccba1e58c 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -54,7 +54,7 @@ When any diagnostic needs captain attention, report the plain consequence and re Verify process reaping, the local-copy return, and endpoint closure without closing or lifting the captain's call, then reconcile any surviving resource. - `BOOTSTRAP_INFO: the captain had already answered the call for before cleanup finished; its endpoint or local copy may remain and should be reconciled` - the captain answered the held call before replay ran, so replay only finished the record side; the durable transition says physical cleanup was interrupted. The answer stands - do not reopen or re-ask the call; verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource. -- `BOOTSTRAP_INFO: retired the pending close for ; its backlog row had already left this backlog, so no close was left to land and its recorded completion link () was never applied and should be reconciled` - the row was already gone, so replay retired the record without landing any close. +- `BACKLOG_RECONCILE: : the recorded backlog close was retired because its backlog row had already left this backlog, so no close was left to land; its recorded completion link () was never applied and should be reconciled` - the row was already gone, so replay retired the record without landing any close. The named link is the only surviving trace of what the cleanup delivered, so file it where the closed row would have carried it rather than treating the retirement as the end of it; the line ends `it recorded no completion link to reconcile` when the record carried none. The line may also warn that the endpoint or local copy may remain after interrupted cleanup: verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource; do not re-close the row, and never read its absence as evidence that the physical cleanup finished. - `BACKLOG_RECONCILE: : recorded backlog close could not be replayed: ` - this session start found a pending-close record carrying a close or retention transition but could not land it. diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 866bfc1ad6a..0a95b5c57d1 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -54,8 +54,11 @@ # land. That path never pretends a close landed: it reports the absence through # FM_BACKLOG_CLOSE_ROW_ABSENT and names the completion link the retirement # could not apply, so a merged PR or report is never discarded silently. -# A genuine active-row lookup failure (an unreadable backlog, a misconfigured -# backend, the wrong home) is preserved as an error for a later retry. +# Absence is whatever this home's configured backend answers: a NOT_FOUND from +# it is trusted, and nothing distinguishes a row that aged out from one this +# backlog never carried. Only a lookup ERROR - an unreadable backlog, an +# unresolvable or incompatible backend, any non-NOT_FOUND failure - is preserved +# as an error for a later retry. # Spawn needs no marker: # it publishes the meta first, so a crash # leaves the meta itself as the evidence that the row is owed a start. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index b9328fc8dfc..16d65d3547e 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -99,8 +99,9 @@ # never starts a captain-held or closed item, and reconciliation never # reads or writes another home; the fleet snapshot's classifier and # bin/fm-secondmate-reconcile.sh's nudge stay as backstops. Replayed -# transitions, retired records, and restored In-flight rows print -# BOOTSTRAP_INFO facts. +# transitions and restored In-flight rows print BOOTSTRAP_INFO facts; +# a record retired without landing its close leaves an unapplied +# completion link, so it reports through BACKLOG_RECONCILE instead. # The `code-root ` variant is a detect-only local check that runs # even in a read-only session; detect_code_root_backlog_fork owns what # it reports. @@ -1337,7 +1338,7 @@ backlog_record_reconcile() { if [ "$FM_BACKLOG_CLOSE_REPLAY_RESULT" = absent_incomplete ]; then disposition="$disposition, and its endpoint or local copy may remain and should be reconciled" fi - echo "BOOTSTRAP_INFO: retired the pending close for $label; its backlog row had already left this backlog, so no close was left to land and $disposition" + echo "BACKLOG_RECONCILE: $label: the recorded backlog close was retired because its backlog row had already left this backlog, so no close was left to land; $disposition" ;; stale) echo "BOOTSTRAP_INFO: discarded a pending close for $label recorded by a superseded incarnation; the incarnation now on record still owes its own close" diff --git a/docs/configuration.md b/docs/configuration.md index ade4d35b29a..5fb0b22a87f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,8 @@ When the automatic transition gate applies, dispatch and completion are not sepa Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. Completion refuses to report success until the item is closed, with one exception: when the row lookup confirms the item has left the backlog entirely - closed and aged out of `done_keep` retention, or removed outright - no later retry could ever land that close, so the pending-close record retires instead of being kept forever. That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement could not apply, so the artifact can be reconciled by hand. -A genuine active-row lookup failure - an unreadable backlog, a misconfigured backend, the wrong home - is still refused and kept for replay. +Absence is whatever this home's configured backend answers: a `NOT_FOUND` is trusted as absence, so a row this backlog never carried retires the record exactly as an aged-out one does. +Only a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - is still refused and kept for replay. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). Automatic transitions run from the configured data directory's parent, letting that home's effective tasks-axi configuration address its selected adapter while keeping relative scout-report links rooted there. A markdown backlog is additionally addressed by an explicit `--file` at `/backlog.md`, so the change lands in the home that owns the task regardless of the caller's working directory. diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 3bc0b2e587f..82a79daa224 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -2789,6 +2789,8 @@ test_recovery_retires_a_close_for_a_row_archived_by_retention() { "an archived row's absence was reported as a lookup failure instead of a completed close" assert_contains "$out" "had already left this backlog" \ "a retired pending close was resolved silently, leaving the operator to infer it" + assert_contains "$out" "BACKLOG_RECONCILE: $id: the recorded backlog close was retired" \ + "the retirement was reported under a prefix the agent contract treats as no-action, so its unapplied completion link is never acted on" assert_contains "$out" "completion link (local main)" \ "retiring the record discarded the completion link it carried without naming it" pass "recovery retires a pending close whose row already left the backlog through retention" @@ -2815,6 +2817,8 @@ test_recovery_retires_a_close_for_a_row_removed_without_closing() { "a removed row's confirmed absence was reported as a lookup failure" assert_contains "$out" "had already left this backlog" \ "a retired pending close was resolved silently, leaving the operator to infer it" + assert_contains "$out" "BACKLOG_RECONCILE: $id: the recorded backlog close was retired" \ + "the retirement was reported under a prefix the agent contract treats as no-action, so its unapplied completion link is never acted on" assert_contains "$out" "completion link (PR https://example.test/pr/7)" \ "retiring the record discarded the merged PR it carried without naming it" pass "recovery retires a pending close whose row was removed without closing, naming its link" @@ -2846,6 +2850,8 @@ test_recovery_reports_a_row_that_left_the_backlog_mid_close() { "a close whose row left the backlog mid-replay was left to retry forever" assert_contains "$out" "had already left this backlog" \ "replay hid that its close found no row to land against" + assert_contains "$out" "BACKLOG_RECONCILE: $id: the recorded backlog close was retired" \ + "the retirement was reported under a prefix the agent contract treats as no-action, so its unapplied completion link is never acted on" assert_not_contains "$out" "that an interrupted cleanup left open" \ "replay reported a close as landed against a row that had left the backlog" pass "recovery reports a close whose row left the backlog inside its own replay window" From 2a47d04b533ddc2fc97b627bc384fa2b68322bce Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 15:44:35 -0700 Subject: [PATCH 12/17] no-mistakes(review): soften replay wording to unconfirmed completion link --- .agents/skills/bootstrap-diagnostics/SKILL.md | 4 ++-- bin/fm-bootstrap.sh | 2 +- docs/configuration.md | 3 ++- tests/fm-backlog-atomicity.test.sh | 4 ++++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 63ccba1e58c..95a7c006b49 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -54,8 +54,8 @@ When any diagnostic needs captain attention, report the plain consequence and re Verify process reaping, the local-copy return, and endpoint closure without closing or lifting the captain's call, then reconcile any surviving resource. - `BOOTSTRAP_INFO: the captain had already answered the call for before cleanup finished; its endpoint or local copy may remain and should be reconciled` - the captain answered the held call before replay ran, so replay only finished the record side; the durable transition says physical cleanup was interrupted. The answer stands - do not reopen or re-ask the call; verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource. -- `BACKLOG_RECONCILE: : the recorded backlog close was retired because its backlog row had already left this backlog, so no close was left to land; its recorded completion link () was never applied and should be reconciled` - the row was already gone, so replay retired the record without landing any close. - The named link is the only surviving trace of what the cleanup delivered, so file it where the closed row would have carried it rather than treating the retirement as the end of it; the line ends `it recorded no completion link to reconcile` when the record carried none. +- `BACKLOG_RECONCILE: : the recorded backlog close was retired because its backlog row had already left this backlog, so no close was left to land; its recorded completion link () could not be confirmed as applied and should be checked` - the row was already gone, so replay retired the record without landing any close. + The row left the backlog before this replay could read it, so whether an earlier close had already recorded the named link is unknowable here: the line reports it unconfirmed rather than lost. Check where the closed row would have carried it, file it only if it is missing, and do not treat the retirement as the end of it; the line ends `it recorded no completion link to reconcile` when the record carried none. The line may also warn that the endpoint or local copy may remain after interrupted cleanup: verify process reaping, the local-copy return, and endpoint closure, then reconcile any surviving resource; do not re-close the row, and never read its absence as evidence that the physical cleanup finished. - `BACKLOG_RECONCILE: : recorded backlog close could not be replayed: ` - this session start found a pending-close record carrying a close or retention transition but could not land it. A valid teardown record proves the transition was authorized and recorded, but physical cleanup may be partial: verify process reaping, the local-copy return, and endpoint closure before assuming those resources are gone. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 16d65d3547e..2cd20d63201 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -1331,7 +1331,7 @@ backlog_record_reconcile() { ;; absent|absent_incomplete) if [ -n "$FM_BACKLOG_CLOSE_REPLAY_DELIVERABLE" ]; then - disposition="its recorded completion link ($FM_BACKLOG_CLOSE_REPLAY_DELIVERABLE) was never applied and should be reconciled" + disposition="its recorded completion link ($FM_BACKLOG_CLOSE_REPLAY_DELIVERABLE) could not be confirmed as applied and should be checked" else disposition="it recorded no completion link to reconcile" fi diff --git a/docs/configuration.md b/docs/configuration.md index 5fb0b22a87f..730200ec2ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,7 +98,8 @@ A home may instead select another tasks-axi adapter such as Beads through its ow When the automatic transition gate applies, dispatch and completion are not separate operator actions: each moves its work item inside the same run that creates or removes the task's record, so the ordinary successful path cannot leave the backlog and live task set out of sync ([`bin/fm-backlog-transition-lib.sh`](../bin/fm-backlog-transition-lib.sh)). Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. Completion refuses to report success until the item is closed, with one exception: when the row lookup confirms the item has left the backlog entirely - closed and aged out of `done_keep` retention, or removed outright - no later retry could ever land that close, so the pending-close record retires instead of being kept forever. -That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement could not apply, so the artifact can be reconciled by hand. +That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement carried, so the artifact can be reconciled by hand. +Completion says that link was never applied, because its own close demonstrably failed; session start says only that it could not be confirmed as applied, because a row that left the backlog before replay read it may already have carried the link from an earlier close. Absence is whatever this home's configured backend answers: a `NOT_FOUND` is trusted as absence, so a row this backlog never carried retires the record exactly as an aged-out one does. Only a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - is still refused and kept for replay. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 82a79daa224..1d58202d378 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -2852,6 +2852,10 @@ test_recovery_reports_a_row_that_left_the_backlog_mid_close() { "replay hid that its close found no row to land against" assert_contains "$out" "BACKLOG_RECONCILE: $id: the recorded backlog close was retired" \ "the retirement was reported under a prefix the agent contract treats as no-action, so its unapplied completion link is never acted on" + assert_contains "$out" "completion link (local main) could not be confirmed as applied" \ + "replay claimed the recorded link was never applied against a row that was already done before it probed" + assert_not_contains "$out" "was never applied" \ + "replay asserted a link was never applied when its own probe had seen the row done" assert_not_contains "$out" "that an interrupted cleanup left open" \ "replay reported a close as landed against a row that had left the backlog" pass "recovery reports a close whose row left the backlog inside its own replay window" From af66e581bc8cbe5bad465b53dc3bb0c6fd61296b Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 16:08:48 -0700 Subject: [PATCH 13/17] no-mistakes(review): name the failed backlog read in unconfirmed-absence refusal --- bin/fm-backlog-transition-lib.sh | 13 ++++++++++--- tests/fm-backlog-atomicity.test.sh | 8 +++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 0a95b5c57d1..6f579b81450 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -1060,8 +1060,11 @@ fm_backlog_dispatch_rollback() { # A close whose row the probe positively reports as gone from the backlog can # never land, so the record retires rather than promising a retry. Only that -# confirmed NOT_FOUND is accepted: a probe that errors, times out, or still -# finds the row keeps the original close failure and the pending record with it. +# confirmed NOT_FOUND is accepted, and the pending record survives every other +# answer: a probe that still finds the row keeps the original close failure +# alone, while a probe that errors or times out reports that failure together +# with the read error that left the absence unconfirmed, so the refusal names +# the reason it actually acted on rather than the close error it did not. fm_backlog_close_transition() { local meta=$1 marker=$2 data=$3 id=$4 state=$5 close_error shift 5 @@ -1071,7 +1074,11 @@ fm_backlog_close_transition() { close_error=$FM_BACKLOG_TRANSITION_ERROR if fm_backlog_row_probe "$data" "$id" \ || [ "$FM_BACKLOG_ROW_RESULT" != not_found ]; then - FM_BACKLOG_TRANSITION_ERROR=$close_error + if [ "$FM_BACKLOG_ROW_RESULT" = error ]; then + FM_BACKLOG_TRANSITION_ERROR="$close_error; that absence could not be confirmed because this home's backlog row could not be read ($FM_BACKLOG_ROW_ERROR), so the next session start retries the confirmation" + else + FM_BACKLOG_TRANSITION_ERROR=$close_error + fi return 1 fi FM_BACKLOG_CLOSE_ROW_ABSENT=1 diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 1d58202d378..d508f9c7ac2 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -1931,11 +1931,17 @@ test_completion_keeps_a_close_whose_row_lookup_fails() { "teardown discarded the close it still owes after an unconfirmed absence" assert_contains "$out" "could not be closed" \ "teardown hid that the close never landed" + assert_contains "$out" "that absence could not be confirmed because this home's backlog row could not be read" \ + "teardown reported the close's not-found error as the cause while acting on a failed backlog read" + assert_contains "$out" "backlog is unreadable" \ + "teardown discarded the backlog read failure that is the only thing left to fix" + assert_contains "$out" "retries the confirmation" \ + "teardown did not say what the surviving record's retry will settle" assert_contains "$out" "the next session start retries it" \ "teardown dropped the retry the surviving record exists for" assert_not_contains "$out" "had already left" \ "teardown claimed a row had left the backlog without confirming it" - pass "completion keeps a recorded close when the row lookup cannot confirm the absence" + pass "completion keeps a recorded close when the row lookup cannot confirm the absence, naming the read failure" } test_completion_fails_loudly_and_records_the_close_it_still_owes() { From 0db62607f277edd5dcf98975baf0a33b0a3ccf10 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 16:25:41 -0700 Subject: [PATCH 14/17] no-mistakes(review): claim no absence when close reported none --- bin/fm-backlog-transition-lib.sh | 8 ++-- tests/fm-backlog-atomicity.test.sh | 63 +++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 6f579b81450..fd8efd0f735 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -1062,9 +1062,9 @@ fm_backlog_dispatch_rollback() { # never land, so the record retires rather than promising a retry. Only that # confirmed NOT_FOUND is accepted, and the pending record survives every other # answer: a probe that still finds the row keeps the original close failure -# alone, while a probe that errors or times out reports that failure together -# with the read error that left the absence unconfirmed, so the refusal names -# the reason it actually acted on rather than the close error it did not. +# alone, while a probe that cannot read the row reports that failure together +# with the read error, so the refusal says the item's existence was never +# settled instead of asserting an absence the close may never have reported. fm_backlog_close_transition() { local meta=$1 marker=$2 data=$3 id=$4 state=$5 close_error shift 5 @@ -1075,7 +1075,7 @@ fm_backlog_close_transition() { if fm_backlog_row_probe "$data" "$id" \ || [ "$FM_BACKLOG_ROW_RESULT" != not_found ]; then if [ "$FM_BACKLOG_ROW_RESULT" = error ]; then - FM_BACKLOG_TRANSITION_ERROR="$close_error; that absence could not be confirmed because this home's backlog row could not be read ($FM_BACKLOG_ROW_ERROR), so the next session start retries the confirmation" + FM_BACKLOG_TRANSITION_ERROR="$close_error; this home's backlog row could not be read to confirm whether the item still exists ($FM_BACKLOG_ROW_ERROR), so the next session start retries this close" else FM_BACKLOG_TRANSITION_ERROR=$close_error fi diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index d508f9c7ac2..e2a256d7e7f 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -246,6 +246,32 @@ SH # while the row lookup that would confirm that absence fails outright. The # absence is then unconfirmed, which is exactly the case a recorded close must # survive rather than be retired on. +# Shadow tasks-axi so the close fails for a reason that is not an absence and +# the row lookup that follows it also fails. Nothing in that pair says whether +# the item still exists, so the refusal must not claim it had left the backlog. +break_close_and_row_probe() { # + local case_dir=$1 real + real=$(command -v tasks-axi) + cat > "$case_dir/fakebin/tasks-axi" < "$case_dir/close-attempted" + echo 'error: "backlog is unwritable"' >&2 + exit 1 + ;; + show) + if [ -f "$case_dir/close-attempted" ]; then + echo 'error: "backlog is unreadable"' >&2 + exit 1 + fi + ;; +esac +exec "$real" "\$@" +SH + chmod +x "$case_dir/fakebin/tasks-axi" +} + break_row_probe_after_absent_close() { # local case_dir=$1 real real=$(command -v tasks-axi) @@ -1931,11 +1957,11 @@ test_completion_keeps_a_close_whose_row_lookup_fails() { "teardown discarded the close it still owes after an unconfirmed absence" assert_contains "$out" "could not be closed" \ "teardown hid that the close never landed" - assert_contains "$out" "that absence could not be confirmed because this home's backlog row could not be read" \ + assert_contains "$out" "could not be read to confirm whether the item still exists" \ "teardown reported the close's not-found error as the cause while acting on a failed backlog read" assert_contains "$out" "backlog is unreadable" \ "teardown discarded the backlog read failure that is the only thing left to fix" - assert_contains "$out" "retries the confirmation" \ + assert_contains "$out" "retries this close" \ "teardown did not say what the surviving record's retry will settle" assert_contains "$out" "the next session start retries it" \ "teardown dropped the retry the surviving record exists for" @@ -1944,6 +1970,38 @@ test_completion_keeps_a_close_whose_row_lookup_fails() { pass "completion keeps a recorded close when the row lookup cannot confirm the absence, naming the read failure" } +# The same refusal reached from a close that never reported an absence. Both the +# close and the row lookup fail for unrelated reasons, so nothing establishes +# whether the row is still there; the message must name both failures and claim +# no absence, because the record it keeps exists to retry the close itself. +test_completion_refusal_claims_no_absence_when_the_close_never_reported_one() { + local case_dir home id out rc=0 + id=atomic-close-unreadable-b13 + case_dir=$(make_home close-unreadable) + home=$(home_of "$case_dir") + add_item "$case_dir" "$id" + start_item "$case_dir" "$id" + write_task_meta "$case_dir" "$id" ship local-only "spawn_gen=spawn-close-unreadable" + break_close_and_row_probe "$case_dir" + + out=$(run_teardown "$case_dir" "$id") || rc=$? + rm -f "$case_dir/fakebin/tasks-axi" + [ "$rc" -ne 0 ] || fail "teardown reported success after a close that never landed" + assert_present "$home/state/$id.backlog-close" \ + "teardown discarded the close it still owes" + assert_contains "$out" "backlog is unwritable" \ + "teardown dropped the close failure that is the reason it refused" + assert_contains "$out" "backlog is unreadable" \ + "teardown dropped the row-read failure that left the item's existence unsettled" + assert_not_contains "$out" "absence" \ + "teardown asserted an absence for a close that never reported one" + assert_not_contains "$out" "had already left" \ + "teardown claimed the row had left the backlog with no absence ever reported" + assert_contains "$out" "retries this close" \ + "teardown did not say the surviving record exists to retry the close" + pass "completion refuses without claiming an absence when the close never reported one" +} + test_completion_fails_loudly_and_records_the_close_it_still_owes() { local case_dir id out rc=0 id=atomic-close-b7 @@ -3862,6 +3920,7 @@ test_control_character_data_path_is_refused_before_cleanup test_completion_preserves_records_when_meta_removal_fails test_completion_accepts_a_row_already_archived_by_retention test_completion_keeps_a_close_whose_row_lookup_fails +test_completion_refusal_claims_no_absence_when_the_close_never_reported_one test_completion_fails_loudly_and_records_the_close_it_still_owes test_interrupted_destructive_cleanup_leaves_a_recoverable_close test_interrupted_cleanup_of_an_archived_row_still_warns_about_its_endpoint From 0a28998afbdff976a1ea74702227e1f34fae0be7 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 16:44:14 -0700 Subject: [PATCH 15/17] no-mistakes(review): align teardown link wording with session start --- bin/fm-teardown.sh | 2 +- docs/configuration.md | 2 +- tests/fm-backlog-atomicity.test.sh | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 593705a7fbb..5c0b80f700b 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1442,7 +1442,7 @@ backlog_refresh_reminder() { deliverable=$(fm_backlog_retain_deliverable \ "${BACKLOG_DONE_ARGS[@]+"${BACKLOG_DONE_ARGS[@]}"}") if [ -n "$deliverable" ]; then - disposition="its completion link ($deliverable) was never applied - reconcile that artifact by hand." + disposition="its completion link ($deliverable) could not be confirmed as applied and should be checked." else disposition="it recorded no completion link to reconcile." fi diff --git a/docs/configuration.md b/docs/configuration.md index 730200ec2ca..98d2524932b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,7 +99,7 @@ When the automatic transition gate applies, dispatch and completion are not sepa Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. Completion refuses to report success until the item is closed, with one exception: when the row lookup confirms the item has left the backlog entirely - closed and aged out of `done_keep` retention, or removed outright - no later retry could ever land that close, so the pending-close record retires instead of being kept forever. That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement carried, so the artifact can be reconciled by hand. -Completion says that link was never applied, because its own close demonstrably failed; session start says only that it could not be confirmed as applied, because a row that left the backlog before replay read it may already have carried the link from an earlier close. +Neither path claims the link landed, and neither claims it did not: a close can be killed after its write reached the backlog but before it reported success, so a failed close is not proof that nothing was recorded. Completion and session start therefore both say only that the link could not be confirmed as applied and should be checked. Absence is whatever this home's configured backend answers: a `NOT_FOUND` is trusted as absence, so a row this backlog never carried retires the record exactly as an aged-out one does. Only a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - is still refused and kept for replay. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index e2a256d7e7f..997d6984849 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -1930,8 +1930,10 @@ test_completion_accepts_a_row_already_archived_by_retention() { "teardown kept the task record for a row already gone from the backlog" assert_contains "$out" "had already left" \ "teardown accepted the absence without telling the operator the row was gone" - assert_contains "$out" "completion link (local main)" \ - "teardown discarded the completion link its close could not apply" + assert_contains "$out" "completion link (local main) could not be confirmed as applied" \ + "teardown asserted the recorded link was never applied, which a close killed after its write had landed disproves" + assert_not_contains "$out" "was never applied" \ + "teardown claimed the link was never applied without proving no write landed" assert_not_contains "$out" "is closed in" \ "teardown reported a close it never ran as landed in a backlog holding no row" pass "completion accepts a row retention already archived as the close it was reaching for" From 1889a5fa762264377be97d7b8d8068ecf7ba0a12 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 17:17:14 -0700 Subject: [PATCH 16/17] no-mistakes(document): align retired-close doc wording with softened link claim --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 98d2524932b..63478d9c125 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,7 +98,7 @@ A home may instead select another tasks-axi adapter such as Beads through its ow When the automatic transition gate applies, dispatch and completion are not separate operator actions: each moves its work item inside the same run that creates or removes the task's record, so the ordinary successful path cannot leave the backlog and live task set out of sync ([`bin/fm-backlog-transition-lib.sh`](../bin/fm-backlog-transition-lib.sh)). Under that gate, dispatch accepts only an unheld, unblocked Queued or In flight item in this home; a missing, Done, held, or dependency-blocked item is refused before any endpoint or local copy is created. Completion refuses to report success until the item is closed, with one exception: when the row lookup confirms the item has left the backlog entirely - closed and aged out of `done_keep` retention, or removed outright - no later retry could ever land that close, so the pending-close record retires instead of being kept forever. -That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement carried, so the artifact can be reconciled by hand. +That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement carried, so the artifact can be checked by hand. Neither path claims the link landed, and neither claims it did not: a close can be killed after its write reached the backlog but before it reported success, so a failed close is not proof that nothing was recorded. Completion and session start therefore both say only that the link could not be confirmed as applied and should be checked. Absence is whatever this home's configured backend answers: a `NOT_FOUND` is trusted as absence, so a row this backlog never carried retires the record exactly as an aged-out one does. Only a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - is still refused and kept for replay. From 5977a32f57960d7561efb09a8da0c8420f3f4051 Mon Sep 17 00:00:00 2001 From: rub-a-dub-dub Date: Sat, 26 Sep 2026 21:45:25 -0700 Subject: [PATCH 17/17] no-mistakes(document): correct refused-lookup set in retired-close backlog docs --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 63478d9c125..39dc0496726 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,7 +101,7 @@ Completion refuses to report success until the item is closed, with one exceptio That retirement never reports a close it did not make: completion and session start both say the row had already left the backlog, and both name the completion link (the merged PR, the scout report, or `local main`) the retirement carried, so the artifact can be checked by hand. Neither path claims the link landed, and neither claims it did not: a close can be killed after its write reached the backlog but before it reported success, so a failed close is not proof that nothing was recorded. Completion and session start therefore both say only that the link could not be confirmed as applied and should be checked. Absence is whatever this home's configured backend answers: a `NOT_FOUND` is trusted as absence, so a row this backlog never carried retires the record exactly as an aged-out one does. -Only a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - is still refused and kept for replay. +Every other answer is still refused and kept for replay: a lookup that still finds the row, and a lookup error - an unreadable backlog, an unresolvable or incompatible backend, any failure that is not `NOT_FOUND` - alike. When a spawn is interrupted after launch delivery began, its exit path re-reads the paired task record and the backlog row under the same per-task lock as the commit, repairs a row the commit believed it had moved, and reports only what was verified or honestly attempted, never intent phrased as outcome ([`bin/fm-spawn.sh`](../bin/fm-spawn.sh); [`tests/fm-backlog-atomicity.test.sh`](../tests/fm-backlog-atomicity.test.sh)). Automatic transitions run from the configured data directory's parent, letting that home's effective tasks-axi configuration address its selected adapter while keeping relative scout-report links rooted there. A markdown backlog is additionally addressed by an explicit `--file` at `/backlog.md`, so the change lands in the home that owns the task regardless of the caller's working directory.