Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 89 additions & 16 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
# Usage:
# fm-captain-hold.sh hold <task-id> --reason <reason> \
# [--title <title>] [--repo <repo>] [--origin <origin-id>] [--until YYYY-MM-DD]
# fm-captain-hold.sh answer <task-id> --decision-file <path> [--release]
# fm-captain-hold.sh answer <task-id> --decision-file <path> [--release] [--expect-identity <identity>]
# fm-captain-hold.sh answers [<legacy-origin> | --any-origin] --source <provenance> (keyed answers on stdin)
# fm-captain-hold.sh reconcile-requests --source-id <source-id> --source <provenance> (task ids on stdin)
# fm-captain-hold.sh bind <source-id> [<legacy-origin> | --any-origin]
Expand All @@ -47,8 +47,12 @@
# Beads `due.required` through `BD_DUE_REQUIRED` rather than inventing a due
# date or registering a `types.custom` captain issue type. The command records
# a UTC `Captain hold set:` timestamp in the task body: repeating an active
# hold preserves the existing timestamp, while re-holding released work starts
# a new lifecycle. A task already closed is refused rather than reopened.
# hold with an identical reason preserves the existing timestamp (a changed
# `--until` alone keeps it), while re-holding it with a reworded reason, or
# re-holding released work, starts a new lifecycle stamp that is always later
# than the old one: one second past it whenever the clock is not already past
# it, so an earlier stamp never recurs. A body-only edit keeps the stamp. A
# task already closed is refused rather than reopened.
# `--until` records the captain's own deferral date through `tasks-axi hold
# --until`, so a "revisit later" answer is stored as a date instead of a live
# card.
Expand All @@ -70,6 +74,20 @@
# answered captain call. A hold that expired by date (`--until` in the past) is
# still answerable: the surviving hold annotations, not tasks-axi's live
# `held:` bit, prove the captain owned it.
# `--expect-identity` makes the answer a compare-and-answer for a caller that
# showed the captain one exact question, such as a phone approval card: under
# the same task lock the answer takes, the task's current `open --identity`
# value must equal the given one, or `answer` exits 3, prints why on stderr,
# and records nothing. A call that is no longer open has no identity, so a
# closed, released, or re-held task (even one re-held with the same words)
# refuses a stale expectation, including a replay of an answer that already
# landed; a task confirmed absent refuses the same way, while a backlog read
# that fails keeps the ordinary failure exit. A live call re-held with a
# reworded reason starts a new lifecycle stamp both before and after it records
# the new reason, so a tap on the old wording is refused too, even when the
# hold stops between the two, while a re-hold with an identical reason keeps
# the stamp and the identity the captain was shown.
# Without the option the answer behaves exactly as described above.
#
# ONE KEYED-ANSWER INTAKE, FED BY EVERY CHANNEL.
# "A keyed answer resolves its matching captain-held task" is a single
Expand Down Expand Up @@ -171,7 +189,8 @@
# hold-set stamp and the count of recorded answers - is what distinguishes two
# successive calls on one task id: re-holding released work starts a new
# lifecycle without necessarily touching the task's status log, so a consumer
# that bounds repeated work per call cannot use the task id alone.
# that bounds repeated work per call cannot use the task id alone, and
# `answer --expect-identity` compares against the same value.
# bin/fm-teardown.sh asks it before its automatic
# backlog close and, on 0, returns the row to Queued with its deliverable
# recorded instead (bin/fm-backlog-transition-lib.sh owns that transition), so
Expand Down Expand Up @@ -760,14 +779,35 @@ body_hold_set_timestamp() { # <decoded-task-body>
| head -1
}

# The lifecycle identity `open --identity` prints and `answer --expect-identity`
# compares: the hold-set stamp and the count of recorded answers.
captain_call_identity() { # <shown-task-body>
printf '%s#%s\n' \
"$(body_hold_set_timestamp "$(decode_shown_value "$1")")" \
"$(resolution_record_count "$1")"
}

next_hold_set_second() { # <YYYY-MM-DDTHH:MM:SSZ>
local epoch
epoch=$(fm_utc_iso_to_epoch "$1") || return 1
epoch=$((epoch + 1))
date -u -r "$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -u -d "@$epoch" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null
}

write_hold_set_stamp() { # <task-id> <shown-body> <timestamp> <preserve-existing-0-or-1>
local id=$1 body=$2 hold_set=$3 preserve=$4 existing new_body tmp
local id=$1 body=$2 hold_set=$3 preserve=$4 existing existing_epoch new_body tmp
body=$(decode_shown_value "$body") \
|| fail "could not decode the existing body for $id"
existing=$(body_hold_set_timestamp "$body")
if [ "$preserve" = 1 ] && [ -n "$existing" ]; then
return 0
fi
if existing_epoch=$(fm_utc_iso_to_epoch "$existing") \
&& [ "$(fm_utc_iso_to_epoch "$hold_set")" -le "$existing_epoch" ]; then
hold_set=$(next_hold_set_second "$existing") \
|| fail "could not advance the hold-set stamp on $id"
fi
if [ -n "$existing" ]; then
body=${body#"Captain hold set: $existing"}
case "$body" in
Expand Down Expand Up @@ -810,9 +850,18 @@ verify_entry_durable() { # <origin-or-empty> <entry>; prints "<id> <how>"
verify_hold_durable "${resolved%% *}"
}

stamp_hold_set() { # <task-id> <timestamp> <preserve-existing-0-or-1>
local id=$1 show
task_show_or_fail "$id" "task $id disappeared before recording its hold-set stamp"
write_hold_set_stamp "$id" "$(show_field "$show" body)" "$2" "$3"
task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp"
[ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \
|| fail "task $id did not retain its hold-set stamp"
}

command_hold() {
local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind hold_set occurrence
local existing_hold_kind='' existing_held='' preserve_hold_set=0
local existing_hold_kind='' existing_held='' preserve_hold_set=0 reword=0
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
shift
while [ "$#" -gt 0 ]; do
Expand Down Expand Up @@ -853,7 +902,11 @@ command_hold() {
existing_hold_kind=$(show_field_value "$show" hold_kind)
existing_held=$(show_field_value "$show" held)
if [ "$existing_hold_kind" = captain ] && [ "$existing_held" = yes ]; then
preserve_hold_set=1
if [ "$(show_field_value "$show" hold_reason)" = "$reason" ]; then
preserve_hold_set=1
else
reword=1
fi
fi
if [ -n "$title" ]; then
existing_title=$(show_field_value "$show" title)
Expand Down Expand Up @@ -884,18 +937,15 @@ command_hold() {
# Publish the timestamp before the captain-hold annotation. A concurrent
# snapshot may see the harmless stamp by itself, but can never see a newly
# held task without the timestamp that defines this hold lifecycle's age.
task_show_or_fail "$id" "task $id disappeared before recording its hold-set stamp"
write_hold_set_stamp "$id" "$(show_field "$show" body)" "$hold_set" "$preserve_hold_set"
task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp"
[ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \
|| fail "task $id did not retain its hold-set stamp"
stamp_hold_set "$id" "$hold_set" "$preserve_hold_set"
if [ -n "$until" ]; then
tasks_axi hold "$id" --reason "$reason" --kind captain --until "$until" >/dev/null \
|| fail "could not hold task $id for the captain"
else
tasks_axi hold "$id" --reason "$reason" --kind captain >/dev/null \
|| fail "could not hold task $id for the captain"
fi
[ "$reword" = 0 ] || stamp_hold_set "$id" "$hold_set" "$preserve_hold_set"
task_show "$id" || fail "task $id disappeared while holding it"
show=$TASK_SHOW_OUTPUT
hold_kind=$(show_field_value "$show" hold_kind)
Expand Down Expand Up @@ -996,25 +1046,50 @@ remove_interrupted_answer_stamp() { # <task-id>

command_answer() {
local id=${1:-} decision_file='' release=0 show state hold_kind body outcome recorded_mode occurrence
local expect_identity='' expect_given=0 current_identity
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--decision-file) shift; decision_file=${1:-} ;;
--release) release=1 ;;
--expect-identity) shift; expect_identity=${1:-}; expect_given=1 ;;
*) usage >&2; exit 2 ;;
esac
shift
done
validate_slug task-id "$id"
[ "$expect_given" = 0 ] || validate_one_line expect-identity "$expect_identity"
load_decision "$decision_file"
acquire_task_control_lock "$id"
require_tasks_axi
task_show "$id" || fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)"
if ! task_show "$id"; then
if [ "$expect_given" = 1 ] \
&& ! fm_backlog_row_probe "$(fm_backlog_data_absolute "$DATA")" "$id" \
&& [ "$FM_BACKLOG_ROW_RESULT" = not_found ]; then
printf 'fm-captain-hold: captain call %s changed since it was shown (expected %s, now absent); nothing recorded\n' \
"$id" "$expect_identity" >&2
exit 3
fi
fail "captain-held task $id is absent from this home's configured backlog (data directory $DATA)"
fi
show=$TASK_SHOW_OUTPUT
state=$(show_field "$show" state)
hold_kind=$(show_field_value "$show" hold_kind)
body=$(show_field "$show" body)
if [ "$expect_given" = 1 ]; then
# Compare-and-answer: the same test `open --identity` applies, read under
# this answer's own task lock so no hold or answer can land in between.
current_identity=''
if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then
current_identity=$(captain_call_identity "$body")
fi
if [ "$current_identity" != "$expect_identity" ]; then
printf 'fm-captain-hold: captain call %s changed since it was shown (expected %s, now %s); nothing recorded\n' \
"$id" "$expect_identity" "${current_identity:-not open}" >&2
exit 3
fi
fi
if [ "$release" = 1 ]; then outcome=released; else outcome=answered; fi
# The occurrence the parent line names: the record about to be written is
# one past those already in the body, and a retry names the newest one.
Expand Down Expand Up @@ -1935,9 +2010,7 @@ command_open() { # <task-id> [--identity] [--distinguish-absent]
}
show=$TASK_SHOW_OUTPUT
shown_body=$(show_field "$show" body)
printf '%s#%s\n' \
"$(body_hold_set_timestamp "$(decode_shown_value "$shown_body")")" \
"$(resolution_record_count "$shown_body")"
captain_call_identity "$shown_body"
fi
return 0
fi
Expand Down
13 changes: 10 additions & 3 deletions docs/captain-hold-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ Publishing the stamp first ensures a snapshot cannot observe a newly captain-hel

Repeat and edge cases:

- Retries of an active hold preserve its hold-set timestamp.
- Re-holding released work starts a new timestamped lifecycle.
- Retries of an active hold with an identical reason preserve its hold-set timestamp, even when only `--until` changes.
- Re-holding an active hold with a reworded reason, or re-holding released work, starts a new timestamped lifecycle whose stamp is always later than the old one.
- A closed task is refused rather than reopened.
- `--until` stores the captain's own deferral date through tasks-axi's date gate.

Expand All @@ -71,6 +71,7 @@ The `answer` subcommand records the captain's exact words and resolves the call
| --- | --- |
| `answer` | Closes a question-shaped call. |
| `answer --release` | Frees a captain-gated work item to proceed without completing it. |
| `answer --expect-identity <identity>` | Records the answer only when the call's current `open --identity` value still matches, and otherwise exits 3 and records nothing. |

It requires a non-empty captain decision file of at most 8192 bytes.
It then works in this order:
Expand All @@ -83,6 +84,11 @@ It then works in this order:
If the close is interrupted, the still-held task therefore keeps its original age basis.
A matching retry also completes any resolution-first normalization left unfinished after the close itself succeeded.

`--expect-identity` serves a caller that showed the captain one exact question, such as a phone approval card.
The comparison runs under the answer's own task lock, so a hold or answer cannot land between the check and the record.
The identity is the hold-set stamp plus the count of recorded answers, so a closed, released, or re-held call refuses a stale expectation, and a reworded live call refuses a tap on its old wording.
The command header in `bin/fm-captain-hold.sh` owns the exact refusal and failure cases.

### Answer retries and tasks closed elsewhere

- An exact retry is idempotent only when the requested close mode matches the newest record.
Expand Down Expand Up @@ -520,7 +526,8 @@ The suite does not test the accepted merge-to-cleanup re-hold window or asynchro
- Answer-time resolution works through a bound channel with task-id keys.
This includes the `release` mode, mode-matched replay idempotence, and the refusal of drifted, mode-mismatched, absent, unheld, and already-closed keys.
- The chat channel reaches the same intake.
- Hold-set stamping precedes visible hold state, preserves an active lifecycle's timestamp, and resets after release.
- Hold-set stamping precedes visible hold state, preserves an active lifecycle's timestamp for an identical reason, and moves strictly later after a reword or release.
- `answer --expect-identity` records nothing for a stale identity, including a same-worded re-hold and a reworded live call, and keeps the ordinary failure when the backlog read fails.
- Interrupted answer closure retains the stamp until close and restores resolution-first ordering on retry.
- Deferral through `--until` leaves `captain_actionable` false until due.

Expand Down
Loading
Loading