Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
95 changes: 82 additions & 13 deletions bin/fm-public-followup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# state/x-context/ the private full request context (fm-x-lib.sh).
# bin/fm-x-reply.sh posting to the relay, thread splitting, dry run.
# bin/fm-public-followup-lib.sh the activation gate and private transport.
# bin/fm-on.sh the SSH route to a REMOTE secondmate home, whose
# state no local path can reach.
# This script composes them; it never restates their contracts or schemas.
#
# ZERO OVERHEAD FOR HOMES THAT DO NOT USE THE RELAY: every subcommand gates
Expand Down Expand Up @@ -105,7 +107,12 @@
# fm-public-followup.sh retire <obligation-id> --reason "<why the loop is done>" [--force]
# The only close. Drops the registration after recording --reason.
# --force is the explicit discard-approved escape hatch for an unresolved
# or missing obligation. --reason is required.
# or missing obligation. --reason is required. --force never covers
# clearing the bound legacy X link: a loop whose link is still verifiably
# in place is retained for reconciliation either way. When the bound work
# lives in a REMOTE secondmate home, that clear runs over the route's SSH
# transport, and a remote that never confirms it is reported as unknown
# completion to reconcile on that host, not as a definite failure.
#
# Requires jq and a compatible tasks-axi for registration, briefs,
# reconciliation, delivery, cleanup guards, and retirement; only `active`
Expand Down Expand Up @@ -698,18 +705,61 @@ public_followup_secondmate_home() {
printf '%s\n' "$home"
}

# public_followup_route_is_remote <secondmate-id>: 0 when data/secondmates.md
# holds a genuine REMOTE route for that id. The registry is the route authority
# here for the same reason fm-on.sh and fm-send.sh treat it as one: a remote home
# has no local path, so nothing on this disk can answer the question. Resolving
# it live also means a registration written before this check (they all record an
# empty work_home_path for a remote route) still retires.
public_followup_route_is_remote() {
local id=$1 remote
fm_pf_home_id_valid "secondmate:$id" || return 1
[ -f "$DATA/secondmates.md" ] && [ ! -L "$DATA/secondmates.md" ] || return 1
remote=$(secondmate_registry_field "$DATA/secondmates.md" "$id" remote 2>/dev/null) || return 1
[ "$remote" = 1 ]
}

# clear_public_followup_link_remote <secondmate-id> <work-id> <request-id>:
# clear the bound legacy X link inside a REMOTE secondmate home over that route's transport,
# because the link lives in the remote home's state and no local path reaches it.
# fm-on.sh returns ssh's status unchanged, so 255 is the established "delivered
# but completion unknown" status this codebase already reconciles rather than
# reads as done or refused (bin/fm-on.sh, bin/fm-remote-readiness-lib.sh,
# bin/fm-teardown.sh). It is passed through so a caller can say the remote never
# confirmed instead of claiming the clear definitely failed. The remote clear
# is guarded by the registration's Relay request identity and remains idempotent
# when the target has no link, so a reconciling retry is safe.
clear_public_followup_link_remote() {
local id=$1 work_id=$2 request_id=$3 rc=0
"$FM_ROOT/bin/fm-on.sh" "$id" fm-x-followup.sh --clear "$work_id" \
--expect-request "$request_id" </dev/null >/dev/null || rc=$?
[ "$rc" -ne 255 ] || return 255
[ "$rc" -eq 0 ] || return 1
return 0
}

# Returns 0 when the link is cleared, 255 when a remote home never confirmed the
# clear (completion unknown), and 1 for any other refusal.
clear_public_followup_link() {
local id=$1 work_home work_home_path work_id home state rc
local id=$1 work_home work_home_path work_id request_id home state rc
public_followup_registration_valid "$id" || return 1
work_home=$(fm_pf_registry_get "$STATE" "$id" work_home)
work_id=$(fm_pf_registry_get "$STATE" "$id" work_id)
request_id=$(fm_pf_registry_get "$STATE" "$id" request_id)
[ -n "$work_home" ] && [ -n "$work_id" ] || return 1
case "$work_home" in
main)
home=$FM_HOME
state=$STATE
;;
secondmate:*)
# A remote route is decided from the registry BEFORE any local path is
# consulted: the recorded remote home path is meaningful only on its own
# host, so a same-named local directory must never stand in for it.
if public_followup_route_is_remote "${work_home#secondmate:}"; then
clear_public_followup_link_remote "${work_home#secondmate:}" "$work_id" "$request_id"
return $?
fi
work_home_path=$(fm_pf_registry_get "$STATE" "$id" work_home_path)
case "$work_home_path" in /*) ;; *) return 1 ;; esac
case "$work_home_path" in *$'\n'*|*$'\r'*) return 1 ;; esac
Expand All @@ -734,6 +784,17 @@ clear_public_followup_link() {
"$FM_ROOT/bin/fm-x-followup.sh" --clear "$work_id" >/dev/null
}

# pf_link_clear_note <rc>: the qualifier appended to a refusal when a bound
# legacy X link is still in place. Empty for every local refusal, so those
# messages are unchanged. A remote clear returns fm-on.sh's pass-through ssh
# status, where 255 means the remote home never confirmed the clear: completion
# is unknown and belongs to that host's reconciliation, never a definite failure
# and never a silent success.
pf_link_clear_note() {
[ "$1" -eq 255 ] || return 0
printf ' The remote home never confirmed the clear, so reconcile it on that host rather than assuming nothing changed.'
}

public_followup_legacy_link_status() {
local payload=$1 relations work_home work_id home meta
if ! printf '%s' "$payload" | jq -e '
Expand Down Expand Up @@ -833,7 +894,7 @@ cmd_deliver() {
|| die "this home has not opted into the myfirstmate relay, so it cannot post a public reply" 1
require_tools

local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status
local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status link_rc
local loop_retained=0
payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1
[ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1
Expand All @@ -847,8 +908,10 @@ cmd_deliver() {
case "$delivery" in
posted|waived)
if public_followup_registration_valid "$id"; then
if ! clear_public_followup_link "$id"; then
die "obligation '$id' is already $delivery, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1
link_rc=0
clear_public_followup_link "$id" || link_rc=$?
if [ "$link_rc" -ne 0 ]; then
die "obligation '$id' is already $delivery, but its legacy X link could not be cleared; the registration was retained for reconciliation$(pf_link_clear_note "$link_rc")" 1
fi
else
link_status=1
Expand Down Expand Up @@ -939,8 +1002,10 @@ EOF
die "dry-run for '$id' did not post; recorded as retryable and left the obligation open" 1
fi
if record_posted "$id" "$attempt" "$request" "$platform" "$chunks"; then
if ! clear_public_followup_link "$id"; then
die "the public reply for '$id' POSTED and its receipt was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1
link_rc=0
clear_public_followup_link "$id" || link_rc=$?
if [ "$link_rc" -ne 0 ]; then
die "the public reply for '$id' POSTED and its receipt was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation$(pf_link_clear_note "$link_rc")" 1
fi
if mark_loop_delivered "$id"; then loop_retained=1; fi
printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks"
Expand Down Expand Up @@ -969,7 +1034,7 @@ EOF
# --- subcommand: record-posted ---------------------------------------------

cmd_record_posted() {
local id=${1:-} attempt='' chunks=''
local id=${1:-} attempt='' chunks='' link_rc
[ -n "$id" ] || { usage; exit 2; }
shift
while [ "$#" -gt 0 ]; do
Expand Down Expand Up @@ -997,8 +1062,10 @@ cmd_record_posted() {

record_posted "$id" "$attempt" "$request" "$platform" "$chunks" \
|| die "tasks-axi refused the receipt for '$id' attempt $attempt; the recorded attempt must match exactly" 1
if ! clear_public_followup_link "$id"; then
die "the receipt for '$id' was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1
link_rc=0
clear_public_followup_link "$id" || link_rc=$?
if [ "$link_rc" -ne 0 ]; then
die "the receipt for '$id' was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation$(pf_link_clear_note "$link_rc")" 1
fi
if mark_loop_delivered "$id"; then loop_retained=1; fi
printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request"
Expand Down Expand Up @@ -1251,7 +1318,7 @@ cmd_rechain() {
# --- subcommand: retire -----------------------------------------------------

cmd_retire() {
local id=${1:-} force=0 reason='' payload delivery task_state registry_file retired_dir retired_at
local id=${1:-} force=0 reason='' payload delivery task_state registry_file retired_dir retired_at link_rc
local retirement_rc=0
[ -n "$id" ] || { usage; exit 2; }
shift
Expand Down Expand Up @@ -1284,8 +1351,10 @@ cmd_retire() {
;;
esac
fi
if ! clear_public_followup_link "$id"; then
die "could not clear the legacy X link for '$id'; its registration was retained for reconciliation" 1
link_rc=0
clear_public_followup_link "$id" || link_rc=$?
if [ "$link_rc" -ne 0 ]; then
die "could not clear the legacy X link for '$id'; its registration was retained for reconciliation$(pf_link_clear_note "$link_rc")" 1
fi
retired_dir=$(fm_pf_retired_dir "$STATE")
retired_at=$(now_rfc3339)
Expand Down
11 changes: 7 additions & 4 deletions bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -938,10 +938,13 @@ _fm_lock_acquire_wait_handoff() { # <lockdir> <caller-pid>

# fm_lock_acquire_wait_bounded <lockdir> <positive-seconds>
#
# Presentation-only acquire variant. It preserves the ordinary wait/reclaim
# behavior until fm-timeout-lib.sh's hard deadline, returns 124 when a live
# holder still owns the lock, and leaves FM_LOCK_HELD_PID naming that holder.
# Mutation-critical callers continue to use fm_lock_acquire_wait.
# Bounded acquire variant. It preserves the ordinary wait/reclaim behavior
# until fm-timeout-lib.sh's hard deadline, returns 124 when a live holder still
# owns the lock, and leaves FM_LOCK_HELD_PID naming that holder.
# Use it where a caller must refuse rather than block: wake presentation, and
# the guarded remote link clear, whose whole contract is to return a
# reconciliation refusal instead of wedging an unattended close.
# Mutation-critical callers that can safely block keep fm_lock_acquire_wait.
fm_lock_acquire_wait_bounded() {
local lockdir=$1 seconds=$2 caller_pid rc owner_pid
case "$seconds" in ''|*[!0-9]*|0) return 2 ;; esac
Expand Down
30 changes: 23 additions & 7 deletions bin/fm-x-followup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
# pruned)
#
# Clear a legacy link without posting:
# fm-x-followup.sh --clear <task-id>
# fm-x-followup.sh --clear <task-id> [--expect-request <request-id>]
# idempotently removes only the X follow-up metadata for a typed terminal
# outcome.
# outcome. With --expect-request, a present link must match that request.
#
# Post (after composing the reply to a file or stdin):
# fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path>
Expand Down Expand Up @@ -72,13 +72,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
. "$SCRIPT_DIR/fm-wake-lib.sh"

usage() {
echo "usage: fm-x-followup.sh --check <task-id> | --clear <task-id> | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2
echo "usage: fm-x-followup.sh --check <task-id> | --clear <task-id> [--expect-request <request-id>] | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2
}

help() {
cat <<'EOF'
usage: fm-x-followup.sh --check <task-id>
fm-x-followup.sh --clear <task-id>
fm-x-followup.sh --clear <task-id> [--expect-request <request-id>]
fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path>
fm-x-followup.sh <task-id> [--image <path>] [--final] -

Expand All @@ -88,6 +88,8 @@ X-mode-linked task and manage the link's follow-up counter.
Options:
--check Print the request_id when a follow-up is due.
--clear Clear only the X follow-up link; never post.
--expect-request <request-id>
With --clear, require a present link to match this request.
--image <path> Attach one local image file; threaded replies attach it to the opener tweet or message.
--final Clear the link after this post regardless of the remaining count.
--text-file <path>
Expand Down Expand Up @@ -117,10 +119,19 @@ case "${1:-}" in
esac

FINAL=0
EXPECT_REQUEST_SET=0
EXPECT_REQUEST=
if [ "${1:-}" = --clear ]; then
MODE=clear
ID=${2:-}
if [ -z "$ID" ] || [ "$#" -gt 2 ]; then usage; exit 2; fi
if [ "$#" -eq 4 ] && [ "${3:-}" = --expect-request ]; then
EXPECT_REQUEST_SET=1
EXPECT_REQUEST=${4-}
elif [ "$#" -ne 2 ]; then
usage
exit 2
fi
if [ -z "$ID" ]; then usage; exit 2; fi
elif [ "${1:-}" = --check ]; then
MODE=check
ID=${2:-}
Expand Down Expand Up @@ -162,8 +173,13 @@ if [ -e "$META" ] || [ -L "$META" ]; then
|| { echo "fm-x-followup: unsafe task record in state/$ID.meta" >&2; exit 1; }
fi
if [ "$MODE" = clear ]; then
fmx_meta_link_clear "$META" \
|| { echo "fm-x-followup: could not clear the link in state/$ID.meta" >&2; exit 1; }
if [ "$EXPECT_REQUEST_SET" -eq 1 ]; then
fmx_meta_link_clear "$META" "$EXPECT_REQUEST" \
|| { echo "fm-x-followup: could not clear the link in state/$ID.meta" >&2; exit 1; }
else
fmx_meta_link_clear "$META" \
|| { echo "fm-x-followup: could not clear the link in state/$ID.meta" >&2; exit 1; }
fi
printf '%s\n' "$ID"
exit 0
fi
Expand Down
62 changes: 56 additions & 6 deletions bin/fm-x-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -976,18 +976,68 @@ fmx_meta_followups_set() {
fm_lock_release "$lock"
}

# fmx_meta_link_clear <meta>: atomically remove the x_request/x_request_ts/
# x_followups and reply-platform lines while preserving every other meta line. Idempotent:
# succeeds whether or not a link is present, and is a no-op when <meta> is
# missing.
# fmx_meta_link_clear <meta> [expected-request]: atomically remove the
# x_request/x_request_ts/x_followups and reply-platform lines while preserving
# every other meta line. With expected-request, a present link is cleared only
# when its request identity matches, and absence succeeds only when the
# authorized parent directory can be inspected safely. That guarded mode also
# bounds its lock wait (FMX_LINK_CLEAR_LOCK_TIMEOUT, default 10 seconds) so an
# unattended remote clear refuses instead of hanging. Unguarded calls remain
# idempotent when <meta> is missing and keep the ordinary unbounded wait.
fmx_meta_link_clear() {
local meta=$1 tmp lock
local meta=$1 expected_set=0 expected='' tmp lock line rid='' link_present=0 parent
local lock_timeout
if [ "$#" -ge 2 ]; then
expected_set=1
expected=$2
parent=${meta%/*}
[ "$parent" != "$meta" ] || parent=.
[ -d "$parent" ] && [ ! -L "$parent" ] && [ -r "$parent" ] \
&& [ -x "$parent" ] || return 1
fm_backlog_record_parent_authorized "$meta" "task record" "$STATE" || return 1
fi
[ ! -L "$meta" ] || return 1
[ -f "$meta" ] || return 0
if [ "$expected_set" -eq 1 ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
x_request=*) link_present=1; rid=${line#*=} ;;
esac
done < "$meta" || return 1
[ "$link_present" -eq 1 ] || return 0
[ -n "$expected" ] && [ -n "$rid" ] && [ "$rid" = "$expected" ] || return 1
[ -w "$parent" ] || return 1
fi
lock=$(fm_meta_lock_path "$meta") || return 1
fm_lock_acquire_wait "$lock"
if [ "$expected_set" -eq 1 ]; then
# A guarded clear runs unattended over the secondmate transport, so it must
# refuse rather than wedge. The parent's writability can flip between the
# check above and lock creation, and the ordinary unbounded wait would then
# retry forever instead of returning the reconciliation refusal this guard
# exists to produce. A bounded acquire turns that race, and a live holder,
# into a refusal. Unguarded local callers keep the ordinary wait unchanged.
lock_timeout=${FMX_LINK_CLEAR_LOCK_TIMEOUT:-10}
case "$lock_timeout" in ''|*[!0-9]*|0) lock_timeout=10 ;; esac
fm_lock_acquire_wait_bounded "$lock" "$lock_timeout" || return 1
else
fm_lock_acquire_wait "$lock"
fi
[ ! -L "$meta" ] || { fm_lock_release "$lock"; return 1; }
[ -f "$meta" ] || { fm_lock_release "$lock"; return 0; }
if [ "$expected_set" -eq 1 ]; then
link_present=0
rid=
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
x_request=*) link_present=1; rid=${line#*=} ;;
esac
done < "$meta" || { fm_lock_release "$lock"; return 1; }
[ "$link_present" -eq 0 ] || {
[ -n "$expected" ] && [ -n "$rid" ] && [ "$rid" = "$expected" ] \
|| { fm_lock_release "$lock"; return 1; }
}
[ "$link_present" -eq 1 ] || { fm_lock_release "$lock"; return 0; }
fi
tmp=$(fmx_meta_tmp "$meta") || { fm_lock_release "$lock"; return 1; }
if ! { grep -vE '^x_request=|^x_request_ts=|^x_followups=|^x_platform=|^x_reply_max_chars=' "$meta" || true; } > "$tmp"; then
rm -f "$tmp"; fm_lock_release "$lock"; return 1
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ Actionable reversible requests run through firstmate's normal intake, backlog, d
Work that completes in the answering turn gets one outcome reply.
Work that spawns a longer-running task gets an acknowledgement reply first; `bin/fm-x-link.sh` records `x_request=`, `x_request_ts=`, `x_followups=0`, and optional reply-platform context in that task's `state/<id>.meta`, while durable per-request context preserves the original platform and budget independently of task links and inbox cleanup.
That link therefore reaches only work whose task record lives in the answering home; work routed to a secondmate is bound instead by a typed promised-final commitment registered with `--work-home secondmate:<id>`, and `bin/fm-x-link.sh` refuses a non-local task with that path named rather than leaving the public promise unbound.
Later milestone wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one for ordinary Relay-linked work. A typed promised-final commitment owns its terminal reply through `bin/fm-public-followup.sh`; after its receipt is validated, `bin/fm-x-followup.sh --clear <task-id>` removes any legacy link without posting another reply.
Later milestone wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one for ordinary Relay-linked work.
A typed promised-final commitment owns its terminal reply through `bin/fm-public-followup.sh`; after its receipt is validated, that owner asks the bound work home to remove any legacy link without posting another reply, routing a REMOTE secondmate clear through its SSH transport with the registration's Relay request identity as the mutation guard.
The [Relay configuration reference](configuration.md#relay-env) owns the exact context retention, platform-resolution, and fail-safe posting contract.
If recovery relinks the same relay request onto a successor task, `fm-x-link.sh --carry-count <n> --carry-ts <epoch> --carry-platform <x|discord> --carry-max <n>` preserves the consumed follow-up count, original 7-day window, and reply split budget instead of granting a fresh local budget or falling back to the wrong platform.
The follow-up helper forwards `--image <path>` to the same reply client when a follow-up needs an image.
Expand Down
Loading
Loading