Skip to content
Open
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
67 changes: 46 additions & 21 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
# `--none` is an explicit semantic attestation that the just-reviewed surface
# has no unresolved captain call, and is refused while the origin still has an
# open keyed status decision. With a non-empty inventory, every listed task is
# verified durable (captain-held, or carrying a recorded resolution),
# verified durable (docs/captain-hold-lifecycle.md owns the inventory checks),
# is never the origin itself, and, when `hold --origin` recorded one, was held
# for this origin; a hold with no recorded origin is accepted on durability
# alone and named in the output,
Expand Down Expand Up @@ -523,17 +523,25 @@ resolution_block() { # <mode>

# Durable state of one captain call: an active captain hold (annotations
# surviving even when a date gate has expired) or a recorded captain answer.
# Without captain annotations, an open task's leading stamp can signal an
# incomplete re-hold or unfinished release normalization; the old record alone
# cannot vouch for that state. Done tasks retain their recorded resolution as
# evidence, and verify_entry_durable separately checks the stored origin.
verify_hold_durable() { # <task-id>
local id=$1 show state hold_kind body
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)
hold_kind=$(show_field_value "$show" hold_kind)
body=$(show_field "$show" body)
if body_has_resolution_record "$body"; then
if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then
return 0
fi
if [ "$state" != "done" ] && [ "$hold_kind" = captain ]; then
if body_has_resolution_record "$body"; then
if [ "$state" != "done" ] \
&& [ -n "$(body_hold_set_timestamp "$(decode_shown_value "$body")")" ]; then
fail "captain-held task $id carries a recorded answer from an earlier call, but its newer hold never completed; re-run the hold or answer it again"
fi
return 0
fi
fail "captain-held task $id is neither held for the captain nor closed with a recorded captain answer"
Expand Down Expand Up @@ -821,7 +829,7 @@ task_identity() {
printf '%s' "$id"
}

write_hold_origin() { # <task-id> <shown-body> <origin-or-empty>
write_hold_origin() { # <task-id> <shown-body> <origin>
local id=$1 body=$2 origin=$3 stamp rest new_body tmp
body=$(decode_shown_value "$body") \
|| fail "could not decode the existing body for $id"
Expand All @@ -830,10 +838,7 @@ write_hold_origin() { # <task-id> <shown-body> <origin-or-empty>
|| fail "task $id lost its hold-set stamp before its origin was recorded"
rest=$(printf '%s\n' "$body" | sed 1d | awk '!/^Captain hold origin: /' \
| awk 'NF || started { started = 1; print }')
new_body=$stamp
if [ -n "$origin" ]; then
new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin")
fi
new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin")
if [ -n "$rest" ]; then
new_body=$(printf '%s\n\n%s' "$new_body" "$rest")
fi
Expand All @@ -845,7 +850,25 @@ write_hold_origin() { # <task-id> <shown-body> <origin-or-empty>
fi
if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then
rm -f -- "$tmp"
fail "could not record the hold origin on $id"
printf 'fm-captain-hold: could not record the hold origin on %s\n' "$id" >&2
return 1
fi
rm -f -- "$tmp"
}

# Best-effort restoration removes a refused hold attempt's stamp while
# preserving the prior body; the new origin has not been published yet.
# tasks-axi cannot write an empty body, and a task that began empty carries no
# earlier answer for a stale stamp to mislabel, so that case keeps its body.
restore_hold_body() { # <task-id> <shown-original-body>
local id=$1 body tmp
body=$(decode_shown_value "$2") || return 1
[ -n "$body" ] || return 0
tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-restore.XXXXXX") || return 1
if ! printf '%s\n' "$body" > "$tmp" || ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then
rm -f -- "$tmp"
printf 'fm-captain-hold: could not restore the body of %s after an incomplete hold\n' "$id" >&2
return 1
fi
rm -f -- "$tmp"
}
Expand Down Expand Up @@ -902,7 +925,7 @@ verify_entry_durable() { # <origin-or-empty> <entry>; prints "<id> <how> <origi

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 stored_reason previous_origin='' hold_status=0
local existing_hold_kind='' existing_held='' preserve_hold_set=0 stored_reason original_body='' hold_status=0
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
shift
while [ "$#" -gt 0 ]; do
Expand Down Expand Up @@ -975,16 +998,17 @@ 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.
# Resolve the origin identity before stamping or holding. A lookup failure
# leaves an existing task untouched, but a missing row was already created.
if [ -n "$origin" ]; then
origin=$(task_identity "$origin") || exit $?
fi
task_show_or_fail "$id" "task $id disappeared before recording its hold-set stamp"
original_body=$(show_field "$show" body)
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"
if [ -n "$origin" ]; then
origin=$(task_identity "$origin") || exit $?
previous_origin=$(body_hold_origin "$(show_field_value "$show" body)")
write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $?
fi
if [ -n "$until" ]; then
tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \
|| hold_status=$?
Expand All @@ -993,21 +1017,22 @@ command_hold() {
|| hold_status=$?
fi
if [ "$hold_status" -ne 0 ]; then
# A refused re-hold must not associate the previous hold or answer with a
# new origin. Restore the old line verbatim, without resolving it again.
if [ -n "$origin" ]; then
write_hold_origin "$id" "$(show_field "$show" body)" "$previous_origin" || exit $?
fi
# Restore the body without resolving its old origin again. The new origin
# is still unpublished even if this best-effort restoration fails.
restore_hold_body "$id" "$original_body" || true
fail "could not hold task $id for the captain"
fi
task_show "$id" || fail "task $id disappeared while holding it"
show=$TASK_SHOW_OUTPUT
hold_kind=$(show_field_value "$show" hold_kind)
[ "$hold_kind" = captain ] || fail "task $id did not retain its captain hold"
occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 ))
[ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \
|| fail "task $id lost its hold-set stamp while being held"
occurrence=$(( $(resolution_record_count "$(show_field "$show" body)") + 1 ))
publish_parent_hold "$id" "$occurrence" needs-decision "$reason"
if [ -n "$origin" ]; then
write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $?
fi
printf '%s\n' "$id"
}

Expand Down
27 changes: 21 additions & 6 deletions docs/captain-hold-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ It works in this order:

1. It uses an existing task, or creates one when nothing exists to hold.
2. It records the task's UTC hold-set timestamp as the leading line of the task body.
3. When `--origin` is supplied, it records the origin on the task, replacing any previous association.
4. It invokes the underlying tasks-axi hold operation.
5. It verifies the hold and timestamp.
3. It invokes the underlying tasks-axi hold operation.
4. It verifies the captain-hold annotations and timestamp.
5. In a secondmate home, it publishes the verified hold to the parent channel.
6. When `--origin` is supplied, it records the origin on the task, replacing any previous association only after the backend hold succeeds.

Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age.

Expand All @@ -63,8 +64,17 @@ Repeat and edge cases:
- Re-holding released work starts a new timestamped lifecycle.
- A closed task is refused rather than reopened.
- `--until` stores the captain's own deferral date through tasks-axi's date gate.
- Before the backend hold runs, `--origin` records the origin the call is held for on its own `Captain hold origin:` body line, which `complete` and `verify` check using backend identities rather than alias spellings.
If that write fails, the backend hold is not attempted.
- `--origin` resolves the backend origin identity before writing the hold-set stamp.
The association lives on its own `Captain hold origin:` body line; [completion inventory checks](#recording-a-reviewed-inventory-complete) own how it is verified.
The ordering above prevents an interrupted or refused backend hold from publishing the new association, even when an old captain hold is still active or date-expired.
- If the backend hold fails, `hold` attempts to restore the previous body.
If restoration also fails, the new stamp can remain, but the previous origin is unchanged.
If the subsequent origin write fails, the command returns nonzero, but the successful captain hold and its stamp remain with the previous association (or with no recorded origin for a new task).
Parent publication is attempted before that write in a secondmate home, so an origin-write failure cannot skip delivery; a channel failure retains the existing `actionable:` diagnostic.
Re-running `hold` retries origin publication without duplicating the parent decision.
- If an incomplete hold leaves a stamp above an earlier answer, the [completion durability boundary](#recording-a-reviewed-inventory-complete) applies.
Replaying the earlier `answer --release` can remove that stamp, and normal work completion can retain the answer, but neither transition changes the stored origin: neither can certify the failed move's new origin.
Re-running `hold` completes the move, and re-running the same `answer --release` also normalizes a release interrupted before its stamp was removed.
- The reason may contain parentheses, semicolons, quotes, and line breaks.
[`bin/fm-hold-reason-lib.sh`](../bin/fm-hold-reason-lib.sh) owns the storage encoding and compatibility rules; [`bin/fm-tasks-axi.sh --help`](../bin/fm-tasks-axi.sh) owns the public read commands and output contract.

Expand Down Expand Up @@ -108,9 +118,12 @@ A post-teardown visual review can complete against the surviving report and dura
`complete` accepts `--none` as an explicit semantic inventory result.
`--none` is refused while the origin still has a lifecycle-open keyed status decision.
Before recording completion, `complete` verifies every listed task against tasks-axi.
A durable entry is an open task with captain-hold annotations (including a date-expired hold), or a task carrying a recorded resolution.
For an open task without those annotations, a leading hold-set stamp above the resolution is refused: the answer alone cannot establish durability while the hold or release normalization is incomplete.
A Done task still requires a recorded resolution, but is not subject to that stamp refusal.
The origin is never its own inventory entry, so a hold that failed cannot be vouched for by the origin row.
For a historical inventory that names its own origin, hold a separate captain task with `--origin`, replace only the invalid entry in the final `decision_keys=` line of the origin metadata with that task id while preserving all other entries, and re-run `complete`.
An entry whose recorded origin differs from the one being completed is refused.
An entry whose recorded origin differs from the one being completed is refused, comparing backend identities rather than alias spellings.
An entry with no recorded origin, such as a hold made before origins were recorded or without `--origin`, is accepted on the durability check alone and named in the output.

With a non-empty inventory, `complete` appends a `captain-held [key=<key>]` transfer event for every still-open keyed status decision.
Expand Down Expand Up @@ -525,6 +538,8 @@ The suite does not test the accepted merge-to-cleanup re-hold window or asynchro
- A report-only unresolved captain call refuses `--none` completion before teardown can erase the source.
- Non-forced scout teardown always requires the durable inventory verification.
- The recorded-answer guard holds: a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call.
- `test_hold_origins_follow_backend_holds` exercises lookup, backend-hold, and origin-write failures for new, active, and released holds, including secondmate publication and retry deduplication.
- `test_interrupted_origin_move_does_not_inherit_the_previous_answer` exercises failed restoration, TERM interruption, active and expired holds, earlier-answer replay, and normal released-work completion against the [hold failure semantics](#creating-a-hold-hold) and inventory checks above.

### Answers, stamps, and deferral

Expand Down
Loading
Loading