diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 6b256e15261..40e78c655ff 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -192,6 +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 (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 @@ -540,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 @@ -614,10 +634,128 @@ 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. 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") || { + 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 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) { + 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 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") \ + || 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 + 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 + 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 +767,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 +826,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 +969,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 +996,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 +1127,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 +2101,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 +2135,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. +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 diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 9e6e66ccb95..37f540aa41b 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1321,6 +1321,196 @@ 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, a live re-held lifecycle +# whose ownership was lost, a newer lifecycle archived unanswered from the +# queue, and a re-held row archived carrying only an earlier lifecycle's answer +# all still refuse. +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" + + # Queued-section retention archives the newer, still-unanswered lifecycle + # after the older answered one; the newest captain row in the archive decides. + run_captain "$home" hold "$call" --reason "captain next window pending" >/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" +} + # 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 +1848,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