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
20 changes: 11 additions & 9 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1621,7 +1621,7 @@ reconcile_note() {
}

command_complete() {
local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open has_meta=0 transfer_rc resolved
local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open has_meta=0 transfer_rc transfers=() resolved
local resolved_how attested_by_prefix=''
[ "$#" -ge 2 ] || { usage >&2; exit 2; }
validate_slug origin-id "$origin"
Expand Down Expand Up @@ -1679,20 +1679,22 @@ EOF

# Transfer every still-open status decision to the durable captain-held
# inventory so the live status fold does not duplicate the same Captain's
# Call item. The transfer line is this home's own bookkeeping close,
# written by the turn that just reviewed the inventory, so it uses the
# guarded self-announced append (bin/fm-wake-lib.sh) and does not wake this
# same session; an append failure still fails this command loudly.
# Call item. The transfer lines are this home's own bookkeeping closes,
# written by the turn that just reviewed the inventory, so they go through
# ONE guarded self-announced append (bin/fm-wake-lib.sh) and do not wake
# this same session; an append failure still fails this command loudly.
if [ -n "$keys" ]; then
while IFS=$'\t' read -r key _verb _summary; do
[ -n "$key" ] || continue
transfer_rc=0
fm_wake_status_append_self_announced "$STATE" "$status_file" \
"captain-held [key=$key]: tracked by $keys" || transfer_rc=$?
[ "$transfer_rc" -ne 2 ] || fail "cannot append the captain-held transfer for $origin/$key"
transfers+=("captain-held [key=$key]: tracked by $keys")
done <<EOF
$open
EOF
if [ "${#transfers[@]}" -gt 0 ]; then
transfer_rc=0
fm_wake_status_append_self_announced "$STATE" "$status_file" "${transfers[@]}" || transfer_rc=$?
[ "$transfer_rc" -ne 2 ] || fail "cannot append the captain-held transfer for $origin"
fi
fi
fi
printf 'complete: %s captain-call inventory reviewed%s%s\n' "$origin" "${keys:+ ($keys)}" \
Expand Down
39 changes: 24 additions & 15 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -659,31 +659,40 @@ fi
# durably sent: enqueued on the inbox plane, submit-confirmed on the typed
# plane. An append failure exits nonzero with the manual close
# command; the decision then stays open and re-surfaces, never silently lost.
# The close is this home's own bookkeeping, written by the very turn that
# answered the decision, so it goes through the guarded self-announced append
# (bin/fm-wake-lib.sh) and does not wake this same session again; any
# concurrent foreign status bytes leave the watcher's wake path untouched.
# All of one answer's closes are this home's own bookkeeping, written by the
# very turn that answered the decisions, so they go through ONE guarded
# self-announced append (bin/fm-wake-lib.sh) and do not wake this same session
# again, including when this home already folded those bytes through OPEN
# DECISIONS without a matching watcher seen marker; any concurrent foreign
# status bytes, or a worker line the fold read but never listed, leave the
# watcher's wake path untouched.
fm_send_close_resolved_keys() { # <answer-text>
local note=$1 k line close_note append_rc still manual_close_cmd
local note=$1 k close_note append_rc still manual_close_cmd close_lines=() i=0
note=$(printf '%s' "$note" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177')
for k in $RESOLVE_STATUS_KEYS; do
close_note=$(fm_send_resolve_close_note "$k" "$note")
line="resolved [key=$k]: $close_note"
fm_cap_line_var "$line"
printf -v manual_close_cmd "printf '%%s\\n' %q >> %q" "$FM_LINE_CAP_LINE" "$RESOLVE_STATUS_FILE"
append_rc=0
fm_wake_status_append_self_announced "$STATE" "$RESOLVE_STATUS_FILE" "$FM_LINE_CAP_LINE" || append_rc=$?
if [ "$append_rc" -eq 2 ]; then
echo "error: the answer was delivered to $T, but decision key '$k' could not be closed in $RESOLVE_STATUS_FILE. Close it manually with: $manual_close_cmd - do not resend the answer." >&2
return 1
fi
still=$(status_open_decisions "$RESOLVE_STATUS_FILE")
fm_cap_line_var "resolved [key=$k]: $close_note"
close_lines+=("$FM_LINE_CAP_LINE")
done
[ "${#close_lines[@]}" -gt 0 ] || return 0
append_rc=0
fm_wake_status_append_self_announced "$STATE" "$RESOLVE_STATUS_FILE" "${close_lines[@]}" || append_rc=$?
if [ "$append_rc" -eq 2 ]; then
printf -v manual_close_cmd ' %q' "${close_lines[@]}"
printf -v manual_close_cmd "printf '%%s\\n'%s >> %q" "$manual_close_cmd" "$RESOLVE_STATUS_FILE"
echo "error: the answer was delivered to $T, but the close for decision key(s) '$RESOLVE_STATUS_KEYS' could not be appended to $RESOLVE_STATUS_FILE. Close it manually with: $manual_close_cmd - do not resend the answer." >&2
return 1
fi
still=$(status_open_decisions "$RESOLVE_STATUS_FILE")
for k in $RESOLVE_STATUS_KEYS; do
case "$still" in
"$k"$'\t'* | *$'\n'"$k"$'\t'*)
printf -v manual_close_cmd "printf '%%s\\n' %q >> %q" "${close_lines[$i]}" "$RESOLVE_STATUS_FILE"
echo "error: the answer was delivered to $T, but decision key '$k' is still open in $RESOLVE_STATUS_FILE; it may have been reopened concurrently or the fold did not accept the close. Close it manually with: $manual_close_cmd - do not resend the answer." >&2
return 1
;;
esac
i=$((i + 1))
done
}

Expand Down
74 changes: 51 additions & 23 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2157,43 +2157,71 @@ fm_wake_status_mark_current() { # <state> <status-file>
fm_wake_status_seen_commit "$1" "$2" "$size" "$ident"
}

# Guarded self-announced status append - the one dedup primitive for a status
# line THIS home's own machinery writes as bookkeeping it has already presented
# in the very turn or tick that writes it (an answerer-closes resolved line, a
# pending-reply escalation close, a captain-held transfer). Such a close must
# not wake the session that wrote it, so this appends the line and then
# advances the watcher's seen marker to cover exactly the appended bytes and
# nothing else. The advance is provenance-gated and fails toward waking:
# - the marker advances ONLY when the file's pre-append signature matched the
# recorded seen marker (every earlier byte was already announced or
# deliberately absorbed), AND the post-append size equals the pre-append
# size plus exactly the appended bytes (no foreign write interleaved);
# - on ANY other condition - missing marker, pending foreign bytes, an
# interleaved writer, an unreadable signature - the line is still appended
# but the marker is left alone, so the watcher surfaces the file normally.
# Guarded self-announced status append - the one dedup primitive for the status
# lines THIS home's own machinery writes as bookkeeping it has already presented
# in the very turn or tick that writes them (answerer-closes resolved lines, a
# pending-reply escalation close, captain-held transfers). Such a close must
# not wake the session that wrote it, so this appends one command's lines
# together and then advances the watcher's seen marker across the appended
# bytes and no byte this home has not already read. The advance is
# provenance-gated and fails toward waking:
# - the marker advances only when this home already read every pre-append
# byte, the post-append size equals that size plus exactly the appended
# bytes (no foreign write interleaved), AND the watcher's own span
# classifier finds no actionable event from its classified offset through
# the post-append end (classifying after the append keeps the just-closed
# decisions from counting as live);
# - "already read" means the watcher's classified seen offset equals the
# pre-append size, or the OPEN DECISIONS fold cursor does and every
# non-blank line the watcher has not classified yet is a keyed
# needs-decision or blocked line, which OPEN DECISIONS listed as open. The
# fold reads bytes it never prints, so a worker's `failed:`, `paused:`,
# `working:`, `resolved` or verb-less line there must still wake, and so
# must a captain-held line, which raises the watcher's needs-decision
# side-band;
# - on ANY other condition - a missing file, pending foreign bytes, an
# interleaved writer, an unreadable size or identity - the lines are still
# appended but the marker is left alone, so the watcher surfaces the file
# normally.
# A later, different line from any other writer grows the size past the marker
# and wakes as before: task identity alone can never suppress new content.
# Returns 0 appended and self-announced, 1 appended but left for the watcher
# (the safe direction), 2 the append itself failed.
fm_wake_status_append_self_announced() { # <state> <status-file> <line>
local state=$1 file=$2 line=$3 marker pre_sig='' pre_size='' pre_ident='' post_size post_ident
fm_wake_status_append_self_announced() { # <state> <status-file> <line>...
local state=$1 file=$2 line appended=0 pre_size='' pre_ident='' post_size post_ident classified folded lag span_rc=0
local LC_ALL=C
shift 2
_fm_wake_require_classify || return 1
marker=$(fm_wake_signal_seen_path "$state" "$file")
if [ -e "$file" ]; then
pre_sig=$(fm_wake_signal_sig "$file") || pre_sig=''
pre_size=$(_fm_status_file_size "$file") || pre_size=''
pre_ident=$(_fm_open_decisions_file_ident "$file") || pre_ident=''
fi
printf '%s\n' "$line" >> "$file" || return 2
[ -n "$pre_sig" ] || return 1
status_presentation_marker_reported_matches "$marker" "$pre_sig" || return 1
[ "$(status_presentation_marker_offset "$marker" "$file")" = "$pre_size" ] || return 1
printf '%s\n' "$@" >> "$file" || return 2
post_size=$(_fm_status_file_size "$file") || return 1
post_ident=$(_fm_open_decisions_file_ident "$file") || return 1
case "$pre_size$post_size" in ''|*[!0-9]*) return 1 ;; esac
[ -n "$pre_ident" ] && [ "$post_ident" = "$pre_ident" ] || return 1
[ "$post_size" -eq $((pre_size + ${#line} + 1)) ] || return 1
for line in "$@"; do appended=$((appended + ${#line} + 1)); done
[ "$post_size" -eq $((pre_size + appended)) ] || return 1
classified=$(fm_wake_signal_seen_size "$state" "$file")
if [ "$classified" != "$pre_size" ]; then
folded=$(status_open_decisions_cursor_offset "$file") || folded=0
[ "$folded" = "$pre_size" ] && [ "$classified" -lt "$pre_size" ] || return 1
lag=$(_fm_status_read_span "$file" "$classified" "$((pre_size - classified))") || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in *[![:space:]]*) ;; *) continue ;; esac
case "$(status_line_verb "$line")" in
needs-decision|blocked) ;;
*) return 1 ;;
esac
_fm_key_before_colon "$line" || _fm_key_at_note_head "$line" >/dev/null || return 1
_fm_decision_key "$line" >/dev/null || return 1
done <<EOF
$lag
EOF
fi
status_span_first_actionable_record "$file" "$classified" >/dev/null || span_rc=$?
[ "$span_rc" -eq 1 ] || return 1
fm_wake_status_seen_commit "$state" "$file" "$post_size" "$post_ident" || return 1
return 0
}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ A queued signal annotation prints every status line still unread at that cursor,
A third bounded section, RECORD DIVERGENCE, prints on the same drains for the opposite failure: the status fold went quiet on a key that the durable captain-held task still shows as open, so the status side reads as complete while the two records contradict each other; `bin/fm-captain-hold.sh diverged` decides what counts and closes nothing, and `docs/captain-hold-lifecycle.md` owns the mechanism.
A failed read, output, or concurrent-replacement check prevents the snapshot cursor from advancing across uncertain bytes, and teardown retires a task's manifest row before that task ID can be reused.
The explicit resolution is written by the actor that answers, not the busy worker: `fm-send`'s `--resolve-key` appends the closing `resolved` line to this home's own copy of the ledger at answer time, which covers crewmates, local secondmates, and remote secondmates identically because a remote mate's escalations reach that local copy through the parent-replies ingest and only the answer message itself crosses the transport.
This home's answerer close, pending-reply escalation close, and captain-held transfer use the provenance-guarded append owned by `bin/fm-wake-lib.sh`, so they advance the watcher marker only across their own bytes when all earlier bytes were already announced; pending or interleaved foreign bytes fail toward an ordinary wake.
This home's answerer close, pending-reply escalation close, and captain-held transfer use the provenance-guarded append owned by `bin/fm-wake-lib.sh`, so they advance the watcher marker past their own bytes only when every earlier byte was already classified by the watcher or listed as an open decision; any other earlier line, and any interleaved foreign write, fails toward an ordinary wake.
A turn-ended-only queue row omits its historical status annotation when that status file exactly matches the same seen marker.
Any direct or remaining historical annotation prints every status line unread at the presentation cursor instead of replaying only the latest line.
`bin/fm-crew-state.sh <id>` is the cheap current-state read for an actionable heartbeat review: it attributes an active or terminal no-mistakes run under the shared run-attribution contract, then keeps that run-step authoritative even if the pane has closed, except that a `blocked:` event reporting a refused or missing daemon socket outranks a potentially stale active run record only while that socket-down declaration is itself the log's latest recognized event, since any later event, including another `blocked:` one, means the crew moved on.
Expand Down
39 changes: 39 additions & 0 deletions tests/fm-send-resolve-key.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ test_answer_send_closes_open_decision() {
assert_contains "$(cat "$log")" "Firstmate instruction waiting" "the doorbell should be rung for the answer"
grep -F 'resolved [key=api-shape]: answered: go with REST' "$home/state/t1.status" >/dev/null \
|| fail "fm-send did not append the closing resolved line:"$'\n'"$(cat "$home/state/t1.status")"
# The drain folded the worker's `working:` line but never listed it, so the
# close must leave the file for the watcher instead of marking it seen.
if FM_STATE_OVERRIDE="$home/state" bash -c '
. "$1"; fm_wake_signal_seen_current "$2" "$3"
' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$home/state/t1.status"; then
fail "the answerer's close hid a worker line the drain never listed"
fi

out=$(drain_out "$home")
if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then
Expand Down Expand Up @@ -360,6 +367,37 @@ test_multiple_keys_close_together() {
pass "fm-send --resolve-key: one answer closes each named key and only those"
}

# Issue 4767: the session-start drain listed both decisions (folding them
# without a watcher seen marker), and one answer closes both. The closes are
# this home's own bookkeeping, so the watcher must not wake it to reread them.
test_multiple_keys_close_after_fold_is_self_announced() {
local dir fb log home rc out
dir="$TMP_ROOT/multi-fold"; mkdir -p "$dir"
fb=$(make_stubs "$dir"); log="$dir/send.log"
home=$(setup_home multi-fold)
fm_write_meta "$home/state/t7.meta" "window=sess:fm-t7" "kind=ship"
{
printf 'needs-decision [key=budget]: approve spend?\n'
printf 'needs-decision [key=vendor]: pick a vendor\n'
} > "$home/state/t7.status"
out=$(drain_out "$home")
printf '%s' "$out" | grep -F '[key=vendor]' >/dev/null \
|| fail "precondition: the drain should list both decisions: $out"

run_send "$fb" "$home" "$log" t7 --resolve-key budget --resolve-key vendor \
"approve spend, pick acme"; rc=$?
expect_code 0 "$rc" "an answer resolving two folded keys should succeed"
FM_STATE_OVERRIDE="$home/state" bash -c '
. "$1"; fm_wake_signal_seen_current "$2" "$3"
' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$home/state/t7.status" \
|| fail "one answer's two closes after an OPEN DECISIONS drain were left to re-wake this home"
out=$(drain_out "$home")
if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then
fail "an answered folded key is still open: $out"
fi
pass "fm-send --resolve-key: one answer's closes after a drain fold never wake this home"
}

test_local_secondmate_answer_marked_and_closed() {
local dir fb log home rc got out closing
dir="$TMP_ROOT/sm"; mkdir -p "$dir"
Expand Down Expand Up @@ -731,6 +769,7 @@ test_not_open_key_refuses_before_send
test_failed_ring_still_closes_at_enqueue
test_failed_enqueue_does_not_close
test_multiple_keys_close_together
test_multiple_keys_close_after_fold_is_self_announced
test_local_secondmate_answer_marked_and_closed
test_remote_secondmate_answer_closes_locally
test_remote_reply_corr_tag_does_not_block_resolve_key
Expand Down
Loading
Loading