From c20e3b50ed2ffe7884f1947bd6ecd23c53b27933 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 00:10:39 +0800 Subject: [PATCH 1/3] fix: verify answered captain calls that Done retention archived The scout completion gate (fm-captain-hold.sh complete/verify) read an inventoried captain call only from the live backlog. tasks-axi's done_keep trim moves answered calls into the configured Done archive, so: - an origin whose inventoried call was answered and then archived failed with "no captain-held task " (aceh-prod-continuous-ingest -> aceh-nul-owed-redo); - a self-held origin whose answered row was archived and then re-added as a fresh cleanup row failed with "neither held for the captain nor closed with a recorded captain answer" (aceh-prod-deploy-1e22). An entry no live row carries, or whose live row never carried a captain hold, is now judged by the newest archived row of that id that did. It is durable only when that row records a resolution. Archived calls with no recorded answer still refuse, and a live row with captain-hold provenance is always judged on its own, so an older archived answer cannot settle a newer lifecycle. complete names each entry the archive evidenced. --- bin/fm-captain-hold.sh | 221 ++++++++++++++++++++---- docs/captain-hold-lifecycle.md | 1 + tests/fm-captain-hold-lifecycle.test.sh | 148 ++++++++++++++++ 3 files changed, 341 insertions(+), 29 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 6b256e15261..8a56eef8928 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -192,6 +192,18 @@ # a single row still held for the captain; two such rows refuse rather than # attest, and `complete` names each prefix-resolved row beside its attested # legacy id so the guess stays auditable. +# Done retention: on the markdown backend tasks-axi's `done_keep` trim moves +# closed rows, answered captain calls included, out of the configured backlog +# into the Done archive tasks-axi resolves for it (`[markdown] archive`, else +# done-archive.md beside the backlog). An inventory entry that no live row +# carries, or whose live row never carried a captain hold (no captain hold +# kind, hold-set stamp, or resolution record - a later row reusing the id), is +# therefore judged by the newest archived row of that id that did carry one: +# it is durable only when that row records a resolution. An archived captain +# row with no recorded answer refuses, an archived row never counts as still +# held, and a live row carrying captain-hold provenance is always judged on its +# own, so an older archived answer cannot settle a newer lifecycle. `complete` +# names each entry the archive evidenced. # # `open` is the read-only predicate a mechanical closer asks before it may # retire a task's row: is this task still an open captain call? Exit 0 means it @@ -614,10 +626,117 @@ resolution_block() { # [hold-set-stamp] fi } +# Whether a row ever carried a captain hold: tasks-axi keeps the captain hold +# kind through a close, `hold` writes a hold-set stamp that survives an +# out-of-band unhold, and a resolution record names the call it settled. +row_has_captain_provenance() { # + [ "$1" = captain ] && return 0 + body_has_resolution_record "$2" && return 0 + case "$2" in + 'Captain hold set: '*|*$'\n''Captain hold set: '*) return 0 ;; + esac + return 1 +} + +# Print every row this home's configured Done archive holds for , oldest +# first, as `\t`; print nothing when the backend keeps +# no markdown archive or the archive does not exist yet. Done retention appends +# pruned rows verbatim under `## Archived ` headings, so each block is +# read through the installed tasks-axi parser and row projection as the Done +# section it left, and the archive path is the one tasks-axi's own backlog +# context resolves for this backlog file. +archived_rows_for_id() { # + local id=$1 data root backend file pkg + data=$(fm_backlog_data_absolute "$DATA") || { + printf 'fm-captain-hold: data directory cannot be resolved: %s\n' "$DATA" >&2 + return 1 + } + root=$(fm_backlog_root "$data") || { + printf 'fm-captain-hold: %s\n' "$FM_BACKLOG_TRANSITION_ERROR" >&2 + return 1 + } + backend=$(fm_tasks_axi_backend_resolve "$root") || return 1 + [ "$backend" = markdown ] || return 0 + file=$(fm_backlog_file "$data") || { + printf 'fm-captain-hold: %s\n' "$FM_BACKLOG_TRANSITION_ERROR" >&2 + return 1 + } + pkg=$(tasks_axi_package_root) || pkg= + if [ -z "$pkg" ] || [ ! -f "$pkg/dist/src/context.js" ]; then + printf 'fm-captain-hold: the installed tasks-axi package layout is not readable; cannot read the Done archive\n' >&2 + return 1 + fi + (cd "$root" && FM_ARCHIVE_GRAMMAR="$pkg/dist/src/backends/markdown-grammar.js" \ + FM_ARCHIVE_VIEW="$pkg/dist/src/view.js" FM_ARCHIVE_CONTEXT="$pkg/dist/src/context.js" \ + node --input-type=module - "$file" "$id" <<'NODE' +import { readFileSync } from "node:fs"; +const { parseBacklog } = await import(process.env.FM_ARCHIVE_GRAMMAR); +const { toRow } = await import(process.env.FM_ARCHIVE_VIEW); +const { resolveTasksContext } = await import(process.env.FM_ARCHIVE_CONTEXT); +const [file, id] = process.argv.slice(2); +const { store } = resolveTasksContext({ cwd: process.cwd(), file }); +let src; +try { + src = readFileSync(store.archivePath, "utf8"); +} catch (error) { + if (error.code === "ENOENT") process.exit(0); + throw error; +} +const doc = parseBacklog(src.replace(/^##[ \t]+Archived\b.*$/gm, "## Done")); +const tasks = []; +for (const section of doc.sections) { + for (const entry of section.entries) { + if (entry.kind === "task") tasks.push(entry.task); + } +} +const rows = []; +for (const task of tasks) { + if (task.id !== id) continue; + const row = toRow(task, { all: tasks, full: true }); + rows.push(String(row.hold_kind ?? "-").replace(/[\t\r\n]/g, " ") + "\t" + + Buffer.from(row.body ?? "", "utf8").toString("base64")); +} +process.stdout.write(rows.length ? rows.join("\n") + "\n" : ""); +NODE + ) +} + +# Judge by the newest archived row that carried a captain hold. Returns 0 +# when that row records a resolution, 3 when it records none, and 1 when no +# archived row of ever carried a captain hold. An unreadable archive stops +# the command instead of reading as absence. +archived_call_answered() { # + local id=$1 rows hold_kind encoded body verdict=1 + rows=$(archived_rows_for_id "$id") \ + || fail "could not read this home's configured Done archive while verifying $id" + while IFS=$'\t' read -r hold_kind encoded; do + [ -n "$hold_kind" ] || continue + [ "$hold_kind" != '-' ] || hold_kind='' + body=$(printf '%s' "$encoded" | base64 -d) || fail "could not decode the archived body of $id" + row_has_captain_provenance "$hold_kind" "$body" || continue + if body_has_resolution_record "$body"; then + verdict=0 + else + verdict=3 + fi + done < - local id=$1 show state hold_kind body +# A live row that never carried a captain hold is a later row reusing the id, +# so the call it names is judged by the Done archive instead (header: "Done +# retention"). +verify_hold_durable() { # ; sets CAPTAIN_CALL_EVIDENCE + local id=$1 show state hold_kind body archived_rc=0 + CAPTAIN_CALL_EVIDENCE=live task_show "$id" || fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)" show=$TASK_SHOW_OUTPUT state=$(show_field "$show" state) @@ -629,6 +748,13 @@ verify_hold_durable() { # if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then return 0 fi + if ! row_has_captain_provenance "$hold_kind" "$(show_field_value "$show" body)"; then + archived_call_answered "$id" || archived_rc=$? + case "$archived_rc" in + 0) CAPTAIN_CALL_EVIDENCE=archived; return 0 ;; + 3) fail "captain-held task $id was archived out of this home's configured backlog with no recorded captain answer, and its live row was never held for the captain" ;; + esac + fi fail "captain-held task $id is neither held for the captain nor closed with a recorded captain answer" } @@ -681,7 +807,7 @@ captain_beads_setting() { # # Read the configured beads graph's row listing for a migration-note scan. # The listing is deliberately re-read per unresolvable key: the cache below -# lives and dies with the command-substitution subshell every resolve_entry +# lives and dies with the command-substitution subshell every live-resolution # call site runs in, so it cannot persist across keys - bounded by a scout # report's handful of attested ids. Returns 0 when the listing loads, and 2 # with the reason on stderr when the graph cannot be read. @@ -824,12 +950,14 @@ resolve_migrated_entry() { # return 2 } -# Resolve one inventory entry or channel key to the task that carries it: the -# exact task id when it exists, else the legacy derived identity, else - on the -# beads backend - the migrated row the markdown-to-beads hold migration wrote. -# Prints " ", where is exact, legacy, migrated-note or -# migrated-prefix, so a caller can record which evidence carried the attestation. -resolve_entry() { # ; prints " " or fails +# Resolve one inventory entry or channel key to the live task that carries it: +# the exact task id when it exists, else the legacy derived identity, else - on +# the beads backend - the migrated row the markdown-to-beads hold migration +# wrote. Prints " ", where is exact, legacy, +# migrated-note or migrated-prefix, so a caller can record which evidence +# carried the attestation. Returns 1 without a message when no live row carries +# the entry, so the completion gate can consult the Done archive first. +resolve_live_entry() { # ; prints " " local origin=$1 entry=$2 legacy migrated rc if task_show "$entry"; then printf '%s exact' "$entry" @@ -849,13 +977,27 @@ resolve_entry() { # ; prints " " or fails 2) return 2 ;; 124) return 124 ;; esac + return 1 +} + +fail_unresolved_entry() { # + local origin=$1 entry=$2 if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then - legacy=$(legacy_hold_id "$origin" "$entry") - fail "no captain-held task $entry and no migrated hold for it in this home's configured backlog (data directory $DATA); the nearest legacy identity $legacy also resolves to nothing" + fail "no captain-held task $entry and no migrated hold for it in this home's configured backlog (data directory $DATA); the nearest legacy identity $(legacy_hold_id "$origin" "$entry") also resolves to nothing" fi fail "no captain-held task $entry and no migrated hold for it in this home's configured backlog (data directory $DATA)" } +resolve_entry() { # ; prints " " or fails + local rc=0 + resolve_live_entry "$1" "$2" || rc=$? + case "$rc" in + 0) return 0 ;; + 1) fail_unresolved_entry "$1" "$2" ;; + esac + return "$rc" +} + # The hold-set stamp and review-by line sit on the body's first two lines, so # both readers split those lines in the shell instead of piping every body # through sed: the due report runs them per held call, and a fork per call is @@ -966,21 +1108,40 @@ write_hold_set_stamp() { # " so the caller can keep the -# attestation evidence. +# as absence. An entry no live row carries is judged by the Done archive +# (header: "Done retention"), exact id first, then the legacy identity. On +# success prints " ", with `archived` when the archive carried +# the answer, so the caller can keep the attestation evidence. verify_entry_durable() { # ; prints " " - local origin=$1 entry=$2 resolved resolve_status=0 - resolved=$(resolve_entry "$origin" "$entry") || resolve_status=$? - if [ "$resolve_status" -ne 0 ]; then - [ "$resolve_status" -ne 124 ] \ - || fail "the backlog backend exceeded its read bound resolving $entry" - exit "$resolve_status" - fi - printf '%s\n' "$resolved" - verify_hold_durable "${resolved%% *}" + local origin=$1 entry=$2 resolved resolve_status=0 candidate candidates archived_rc + resolved=$(resolve_live_entry "$origin" "$entry") || resolve_status=$? + case "$resolve_status" in + 0) + verify_hold_durable "${resolved%% *}" + [ "$CAPTAIN_CALL_EVIDENCE" != archived ] || resolved="${resolved%% *} archived" + printf '%s\n' "$resolved" + return 0 + ;; + 1) : ;; + 124) fail "the backlog backend exceeded its read bound resolving $entry" ;; + *) exit "$resolve_status" ;; + esac + candidates=$entry + if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then + candidates="$candidates $(legacy_hold_id "$origin" "$entry")" + fi + for candidate in $candidates; do + archived_rc=0 + archived_call_answered "$candidate" || archived_rc=$? + case "$archived_rc" in + 0) printf '%s archived\n' "$candidate"; return 0 ;; + 3) fail "captain-held task $candidate was archived out of this home's configured backlog with no recorded captain answer" ;; + esac + done + fail_unresolved_entry "$origin" "$entry" } command_hold() { @@ -1921,7 +2082,7 @@ reconcile_note() { command_complete() { local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open has_meta=0 transfers=() resolved - local resolved_how attested_by_prefix='' + local resolved_how attested_by_prefix='' attested_by_archive='' [ "$#" -ge 2 ] || { usage >&2; exit 2; } validate_slug origin-id "$origin" shift @@ -1955,9 +2116,10 @@ command_complete() { resolved=$(verify_entry_durable "$origin" "$entry") || exit $? resolved_how=${resolved##* } resolved=${resolved%% *} - if [ "$resolved_how" = migrated-prefix ]; then - attested_by_prefix="${attested_by_prefix}${attested_by_prefix:+ }$entry=$resolved" - fi + case "$resolved_how" in + migrated-prefix) attested_by_prefix="${attested_by_prefix}${attested_by_prefix:+ }$entry=$resolved" ;; + archived) attested_by_archive="${attested_by_archive}${attested_by_archive:+ }$resolved" ;; + esac done <]` transfer even Scout teardown calls the read-only `verify` subcommand after checking for the report and before removing any source state. The [script header](../bin/fm-captain-hold.sh) owns the read-only `due` report's eligibility, legacy-date fallback, configuration, and backend limits. `verify` requires the recorded attestation, requires every recorded inventory entry to still be durable (actively captain-held, or carrying a recorded answer), and fails on any keyed status decision that opened after the last `complete`, which makes re-running `complete` the repair. +An answered call that tasks-axi's Done retention trimmed into the configured Done archive stays verifiable from the record the archive keeps, while an archived call with no recorded answer still refuses; the [script header](../bin/fm-captain-hold.sh) "Done retention" paragraph owns the exact rule. The `--force` path remains the explicit captain-approved discard escape hatch. ## Cleanup and captain calls diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 9e6e66ccb95..454f19d8b74 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1321,6 +1321,152 @@ test_out_of_band_close_is_recordable() { pass "an out-of-band close stays recordable through answer" } +# Done retention (tasks-axi `done_keep`) moves an answered captain call's row out +# of the configured backlog into tasks-axi's configured Done archive, and a later +# row may reuse the id. This is the self-held origin shape: the origin held +# itself (the policy prefers holding the gated work item), an earlier pass +# attested `--none`, a later pass inventoried the origin's own id and transferred +# its keyed status decision, the captain answered, retention archived the row, +# and a fresh never-held row was re-added for cleanup. The answer the archive +# still carries must satisfy `complete --none` and `verify`. +test_self_held_call_answered_then_archived_still_verifies() { + local home id meta_before open out + home=$(make_home archived-self-call) + id=sample-deploy-run + tasks_in "$home" add "$id" "Run the sample deploy" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the self-held origin" + write_origin_meta "$home" "$id" + mkdir -p "$home/data/$id" + printf '# Sample deploy\n\nThe deploy was applied on the captain go.\n' > "$home/data/$id/report.md" + printf 'working: preparing the sample deploy\n' > "$home/state/$id.status" + run_captain "$home" complete "$id" --none >/dev/null \ + || fail "the first review pass could not attest an empty inventory" + printf 'needs-decision [key=%s]: deploy now or wait?\n' "$id" >> "$home/state/$id.status" + run_captain "$home" hold "$id" --reason "captain deploy go pending" >/dev/null \ + || fail "the origin could not hold its own captain call" + if run_captain "$home" complete "$id" --none > "$home/open-none.out" 2> "$home/open-none.err"; then + fail "--none was accepted while the self-held call's keyed status decision was open" + fi + run_captain "$home" complete "$id" "$id" >/dev/null \ + || fail "the self-held origin could not inventory its own open call" + [ "$(grep -c '^decisions_reviewed=1$' "$home/state/$id.meta")" = 2 ] \ + || fail "the fixture did not reproduce the two-pass attestation shape" + assert_equals "decision_keys=,decision_keys=$id" \ + "$(grep '^decision_keys=' "$home/state/$id.meta" | paste -sd, -)" \ + "the fixture did not record the empty pass followed by the self-key" + assert_grep "captain-held [key=$id]: tracked by $id" "$home/state/$id.status" \ + "the self-held status decision was not transferred to its captain-held task" + open=$(bash -c '. "$1"; status_open_decisions "$2"' _ \ + "$ROOT/bin/fm-classify-lib.sh" "$home/state/$id.status") + [ -z "$open" ] || fail "the transferred status decision stayed open: $open" + run_captain "$home" verify "$id" >/dev/null \ + || fail "an open self-held call stopped counting as durably held" + + printf 'Captain: deploy now.\n' > "$home/deploy-decision.txt" + run_captain "$home" answer "$id" --decision-file "$home/deploy-decision.txt" --close-live >/dev/null \ + || fail "the captain's answer could not close the self-held call" + printf 'done: sample deploy applied - report at data/%s/report.md\n' "$id" >> "$home/state/$id.status" + tasks_in "$home" prune --keep 0 >/dev/null || fail "Done retention could not archive the answered call" + assert_no_grep "] $id -" "$home/data/backlog.md" "Done retention left the answered row in the live backlog" + assert_grep "[x] $id -" "$home/data/done-archive.md" "Done retention did not archive the answered row" + + tasks_in "$home" add "$id" "Clean up the finished sample deploy" --kind scout --repo sample >/dev/null \ + || fail "could not re-add a cleanup row under the archived id" + meta_before=$(cat "$home/state/$id.meta") + out=$(run_captain "$home" complete "$id" --none 2>&1) \ + || fail "complete --none refused an answered self-held call shadowed by a fresh cleanup row: $out" + run_captain "$home" verify "$id" >/dev/null \ + || fail "verify refused an answered self-held call shadowed by a fresh cleanup row" + assert_equals "$meta_before" "$(cat "$home/state/$id.meta")" \ + "a repeat --none pass appended another attestation" + run_teardown "$home" "$id" >/dev/null 2> "$home/teardown.err" \ + || fail "teardown refused the verified self-held origin: $(cat "$home/teardown.err")" + pass "an answered self-held call verifies after Done retention archives it, even under a fresh reused row" +} + +# The archive is evidence only for an answer it actually records. A call that +# was answered and then trimmed out of the backlog passes; a captain-held call +# closed with no recorded answer and then archived, or a live re-held lifecycle +# whose ownership was lost, still refuses even when an older answered lifecycle +# of the same id sits in the archive. +test_archive_never_settles_an_unanswered_call() { + local home origin call out + home=$(make_home archived-answered-call) + origin=sample-ingest-run + call=sample-redo-call + tasks_in "$home" add "$origin" "Run the sample ingest" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the ingest origin" + write_origin_meta "$home" "$origin" + printf 'working: ingest running\n' > "$home/state/$origin.status" + run_captain "$home" complete "$origin" --none >/dev/null \ + || fail "the ingest origin could not attest an empty inventory" + run_captain "$home" hold "$call" --title "Redo the owed sample rows" \ + --reason "captain redo choice pending" --origin "$origin" >/dev/null \ + || fail "could not hold the redo call" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "the ingest origin could not inventory the redo call" + printf 'A: redo the owed rows now.\n' > "$home/redo-decision.txt" + run_captain "$home" answer "$call" --decision-file "$home/redo-decision.txt" >/dev/null \ + || fail "the captain's answer could not close the redo call" + tasks_in "$home" prune --keep 0 >/dev/null || fail "Done retention could not archive the answered call" + assert_no_grep "] $call -" "$home/data/backlog.md" "Done retention left the answered call in the live backlog" + out=$(run_captain "$home" complete "$origin" --none 2>&1) \ + || fail "complete --none refused a call that was answered and then archived: $out" + run_captain "$home" verify "$origin" >/dev/null \ + || fail "verify refused a call that was answered and then archived" + + home=$(make_home archived-unanswered-call) + call=sample-unanswered-call + tasks_in "$home" add "$origin" "Run the sample ingest" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the second ingest origin" + write_origin_meta "$home" "$origin" + printf 'working: ingest running\n' > "$home/state/$origin.status" + run_captain "$home" hold "$call" --title "Choose the sample retry" \ + --reason "captain retry choice pending" --origin "$origin" >/dev/null \ + || fail "could not hold the unanswered call" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "the ingest origin could not inventory the unanswered call" + tasks_in "$home" "done" "$call" --keep 0 >/dev/null \ + || fail "could not close and archive the call out of band" + assert_grep "[x] $call -" "$home/data/done-archive.md" "the out-of-band close was not archived" + if out=$(run_captain "$home" complete "$origin" --none 2>&1); then + fail "complete --none accepted an archived captain call that records no answer" + fi + assert_contains "$out" "$call" "the archived-unanswered refusal did not name the call" + if run_captain "$home" verify "$origin" > "$home/unanswered-verify.out" 2>&1; then + fail "verify accepted an archived captain call that records no answer" + fi + + home=$(make_home archived-lost-relifecycle) + call=sample-rehold-call + tasks_in "$home" add "$origin" "Run the sample ingest" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the third ingest origin" + write_origin_meta "$home" "$origin" + printf 'working: ingest running\n' > "$home/state/$origin.status" + run_captain "$home" hold "$call" --title "Choose the sample window" \ + --reason "captain window choice pending" --origin "$origin" >/dev/null \ + || fail "could not hold the first lifecycle" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "the ingest origin could not inventory the first lifecycle" + printf 'Tonight.\n' > "$home/window-decision.txt" + run_captain "$home" answer "$call" --decision-file "$home/window-decision.txt" >/dev/null \ + || fail "the captain's answer could not close the first lifecycle" + tasks_in "$home" prune --keep 0 >/dev/null || fail "Done retention could not archive the first lifecycle" + tasks_in "$home" add "$call" "Choose the next sample window" --kind captain --repo sample >/dev/null \ + || fail "could not re-add the call id for a new lifecycle" + run_captain "$home" hold "$call" --reason "captain next window pending" >/dev/null \ + || fail "could not hold the new lifecycle" + run_captain "$home" verify "$origin" >/dev/null \ + || fail "an actively held new lifecycle stopped counting as durably held" + tasks_in "$home" unhold "$call" >/dev/null || fail "could not lift the new hold out of band" + if out=$(run_captain "$home" complete "$origin" --none 2>&1); then + fail "an older archived answer settled a live lifecycle whose hold was lost unanswered" + fi + assert_contains "$out" "neither held for the captain nor closed with a recorded captain answer" \ + "the lost-lifecycle refusal changed its reason" + pass "the archive settles an answered call and never an unanswered or lost one" +} + # A live call past its review-by date is marked due in OPEN DECISIONS while # staying captain-held: the hold bit, the spawn refusal, and the answer path # all survive, and answered, deferred, or not-yet-due calls are not marked. @@ -1658,3 +1804,5 @@ test_deferred_hold_leaves_captains_call_until_due test_board_build_writes_decision_cards test_out_of_band_close_is_recordable test_answer_refuses_close_while_worker_is_live +test_self_held_call_answered_then_archived_still_verifies +test_archive_never_settles_an_unanswered_call From e9827f52b33c8137935fda98f5e1fa3c7820061e Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 00:27:00 +0800 Subject: [PATCH 2/3] fix: judge archived captain calls by their latest lifecycle The Done-archive reader parsed only checked rows, so a newer captain lifecycle archived unanswered from the Queued or In flight section was skipped and an older archived answer of the same id settled it. Normalize every archived open bullet before parsing so the newest captain row decides. An archived row also now counts as answered only when its resolution record settles its latest lifecycle: a re-held row opens with a fresh hold-set stamp, so an earlier lifecycle's record below it no longer settles the call. --- bin/fm-captain-hold.sh | 71 ++++++++++++++++--------- tests/fm-captain-hold-lifecycle.test.sh | 50 +++++++++++++++-- 2 files changed, 92 insertions(+), 29 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 8a56eef8928..40e78c655ff 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -192,18 +192,20 @@ # a single row still held for the captain; two such rows refuse rather than # attest, and `complete` names each prefix-resolved row beside its attested # legacy id so the guess stays auditable. -# Done retention: on the markdown backend tasks-axi's `done_keep` trim moves -# closed rows, answered captain calls included, out of the configured backlog -# into the Done archive tasks-axi resolves for it (`[markdown] archive`, else -# done-archive.md beside the backlog). An inventory entry that no live row -# carries, or whose live row never carried a captain hold (no captain hold -# kind, hold-set stamp, or resolution record - a later row reusing the id), is -# therefore judged by the newest archived row of that id that did carry one: -# it is durable only when that row records a resolution. An archived captain -# row with no recorded answer refuses, an archived row never counts as still -# held, and a live row carrying captain-hold provenance is always judged on its -# own, so an older archived answer cannot settle a newer lifecycle. `complete` -# names each entry the archive evidenced. +# Done retention: on the markdown backend tasks-axi's `done_keep` trim (and any +# `prune` of another section) moves rows, answered captain calls included, out +# of the configured backlog into the Done archive tasks-axi resolves for it +# (`[markdown] archive`, else done-archive.md beside the backlog). An inventory +# entry that no live row carries, or whose live row never carried a captain +# hold (no captain hold kind, hold-set stamp, or resolution record - a later +# row reusing the id), is therefore judged by the newest archived row of that +# id that did carry one, whichever section it was pruned from: it is durable +# only when that row's resolution record settles the row's latest lifecycle, +# so a re-held row archived with only an earlier lifecycle's record refuses. +# An archived captain row with no recorded answer refuses, an archived row +# never counts as still held, and a live row carrying captain-hold provenance +# is always judged on its own, so an older archived answer cannot settle a +# newer lifecycle. `complete` names each entry the archive evidenced. # # `open` is the read-only predicate a mechanical closer asks before it may # retire a task's row: is this task still an open captain call? Exit 0 means it @@ -552,9 +554,15 @@ body_has_resolution_record() { # return 1 } -body_has_current_resolution_record() { - local body stamp +body_has_current_resolution_record() { # + local body body=$(decode_shown_value "$1") || return 1 + decoded_body_has_current_resolution_record "$body" +} + +# Whether the newest record is linked to the hold-set stamp the body opens with. +decoded_body_has_current_resolution_record() { # + local body=$1 stamp stamp=$(body_hold_set_timestamp "$body") [ -n "$stamp" ] || return 1 body_has_resolution_record "$body" || return 1 @@ -640,11 +648,14 @@ row_has_captain_provenance() { # # Print every row this home's configured Done archive holds for , oldest # first, as `\t`; print nothing when the backend keeps -# no markdown archive or the archive does not exist yet. Done retention appends -# pruned rows verbatim under `## Archived ` headings, so each block is -# read through the installed tasks-axi parser and row projection as the Done -# section it left, and the archive path is the one tasks-axi's own backlog -# context resolves for this backlog file. +# no markdown archive or the archive does not exist yet. Retention appends +# pruned rows verbatim under `## Archived ` headings, from whichever +# section was pruned, so the reader normalizes every heading to Done and every +# open bullet (`- [ ] -`, legacy `- **** -`) to a checked one before +# the installed tasks-axi parser and row projection read it: an unanswered row +# archived from Queued or In flight is never skipped, and file order stays the +# archive order. The archive path is the one tasks-axi's own backlog context +# resolves for this backlog file. archived_rows_for_id() { # local id=$1 data root backend file pkg data=$(fm_backlog_data_absolute "$DATA") || { @@ -682,7 +693,11 @@ try { if (error.code === "ENOENT") process.exit(0); throw error; } -const doc = parseBacklog(src.replace(/^##[ \t]+Archived\b.*$/gm, "## Done")); +const normalized = src + .replace(/^##[ \t]+Archived\b.*$/gm, "## Done") + .replace(/^- \[ \] /gm, "- [x] ") + .replace(/^- \*\*([^*\s]+)\*\* - /gm, "- [x] $1 - "); +const doc = parseBacklog(normalized); const tasks = []; for (const section of doc.sections) { for (const entry of section.entries) { @@ -702,9 +717,12 @@ NODE } # Judge by the newest archived row that carried a captain hold. Returns 0 -# when that row records a resolution, 3 when it records none, and 1 when no -# archived row of ever carried a captain hold. An unreadable archive stops -# the command instead of reading as absence. +# when that row settles its latest lifecycle, 3 when it does not, and 1 when no +# archived row of ever carried a captain hold. `answer` leaves its record +# first in the body, while a re-hold puts a fresh hold-set stamp back on top of +# any earlier record, so a body that opens with a stamp is settled only by a +# record linked to that stamp. An unreadable archive stops the command instead +# of reading as absence. archived_call_answered() { # local id=$1 rows hold_kind encoded body verdict=1 rows=$(archived_rows_for_id "$id") \ @@ -714,10 +732,11 @@ archived_call_answered() { # [ "$hold_kind" != '-' ] || hold_kind='' body=$(printf '%s' "$encoded" | base64 -d) || fail "could not decode the archived body of $id" row_has_captain_provenance "$hold_kind" "$body" || continue - if body_has_resolution_record "$body"; then + verdict=3 + body_has_resolution_record "$body" || continue + if [ -z "$(body_hold_set_timestamp "$body")" ] \ + || decoded_body_has_current_resolution_record "$body"; then verdict=0 - else - verdict=3 fi done </dev/null \ + || fail "could not re-hold the new lifecycle" + tasks_in "$home" prune --state queued --keep 0 >/dev/null \ + || fail "queued retention could not archive the new lifecycle" + assert_grep "[ ] $call -" "$home/data/done-archive.md" "queued retention did not archive the unanswered lifecycle" + if out=$(run_captain "$home" complete "$origin" --none 2>&1); then + fail "an older archived answer settled a newer lifecycle archived unanswered from the queue" + fi + assert_contains "$out" "$call was archived out of this home's configured backlog with no recorded captain answer" \ + "the archived-newer-lifecycle refusal did not name the unanswered call" + if run_captain "$home" verify "$origin" > "$home/queued-archive-verify.out" 2>&1; then + fail "verify let an older archived answer settle a newer lifecycle archived from the queue" + fi + + # One row can carry both lifecycles: a released answer stays in the body when + # the row is re-held, and closing that re-held row by hand answers nothing. + home=$(make_home archived-reheld-row) + call=sample-reheld-call + tasks_in "$home" add "$origin" "Run the sample ingest" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the fourth ingest origin" + write_origin_meta "$home" "$origin" + printf 'working: ingest running\n' > "$home/state/$origin.status" + run_captain "$home" hold "$call" --title "Choose the sample batch" \ + --reason "captain batch choice pending" --origin "$origin" >/dev/null \ + || fail "could not hold the released lifecycle" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "the ingest origin could not inventory the released lifecycle" + printf 'Run the first batch.\n' > "$home/batch-decision.txt" + run_captain "$home" answer "$call" --decision-file "$home/batch-decision.txt" --release >/dev/null \ + || fail "the captain's answer could not release the first lifecycle" + run_captain "$home" hold "$call" --reason "captain second batch pending" >/dev/null \ + || fail "could not re-hold the released row" + tasks_in "$home" "done" "$call" --keep 0 >/dev/null \ + || fail "could not close and archive the re-held row out of band" + assert_grep "[x] $call -" "$home/data/done-archive.md" "the re-held row was not archived" + if out=$(run_captain "$home" complete "$origin" --none 2>&1); then + fail "a released answer from an earlier lifecycle settled the re-held row archived unanswered" + fi + assert_contains "$out" "$call was archived out of this home's configured backlog with no recorded captain answer" \ + "the re-held-row refusal did not name the unanswered call" pass "the archive settles an answered call and never an unanswered or lost one" } From f582ef5ddcd0a79c340e3340f28cbbf3dd77b8c3 Mon Sep 17 00:00:00 2001 From: dnth Date: Tue, 20 Oct 2026 00:34:00 +0800 Subject: [PATCH 3/3] no-mistakes(document): Clarify archive-backed captain-call documentation ownership --- docs/captain-hold-lifecycle.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e5d5eb5afe1..44c26953757 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -8,7 +8,7 @@ It was ported from upstream firstmate's `docs/captain-hold-lifecycle.md` for the A decision is not a separate thing in this system: it is an ordinary backlog task held for the captain, and the task id is the identity every surface and channel uses. `bin/fm-captain-hold.sh` is the only lifecycle command layered on that primitive. -The command addresses the active home's configured data directory through `bin/fm-tasks-axi.sh` and the transition library, so the existing backlog remains the only durable work database and a secondmate-owned captain call stays in the secondmate home. +The command addresses the active home's configured data directory through `bin/fm-tasks-axi.sh` and the transition library, so the configured backlog and its tasks-axi archive remain the only durable work database and a secondmate-owned captain call stays in the secondmate home. It never reads report bodies, review artifacts, terminal output, or chat. The `hold` subcommand is the mandatory captain-hold creation path: it uses an existing task or creates one when nothing exists to hold, records its UTC hold-set timestamp and review-by date at the start of the task body, then invokes the underlying tasks-axi hold operation and verifies both records. @@ -33,7 +33,7 @@ With a non-empty inventory it appends a `captain-held [key=]` transfer even Scout teardown calls the read-only `verify` subcommand after checking for the report and before removing any source state. The [script header](../bin/fm-captain-hold.sh) owns the read-only `due` report's eligibility, legacy-date fallback, configuration, and backend limits. `verify` requires the recorded attestation, requires every recorded inventory entry to still be durable (actively captain-held, or carrying a recorded answer), and fails on any keyed status decision that opened after the last `complete`, which makes re-running `complete` the repair. -An answered call that tasks-axi's Done retention trimmed into the configured Done archive stays verifiable from the record the archive keeps, while an archived call with no recorded answer still refuses; the [script header](../bin/fm-captain-hold.sh) "Done retention" paragraph owns the exact rule. +The [script header](../bin/fm-captain-hold.sh) "Done retention" paragraph owns archive-backed completion and verification, including reused task ids and unanswered archived lifecycles. The `--force` path remains the explicit captain-approved discard escape hatch. ## Cleanup and captain calls