Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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
48 changes: 43 additions & 5 deletions bin/fm-x-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -976,18 +976,56 @@ 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. Unguarded calls remain
# idempotent when <meta> is missing.
fmx_meta_link_clear() {
local meta=$1 tmp lock
local meta=$1 expected_set=0 expected='' tmp lock line rid='' link_present=0 parent
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
lock=$(fm_meta_lock_path "$meta") || return 1
if [ "$expected_set" -eq 1 ] && [ ! -w "$parent" ]; then
# A new publisher cannot acquire this lock while the directory is read-only.
# Refuse if an earlier publisher already owns it; that owner may make the
# directory writable and publish after an unlocked absence scan.
[ ! -e "$lock" ] && [ ! -L "$lock" ] || return 1
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 0 ] || return 1
return 0
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
fi
fm_lock_acquire_wait "$lock"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
[ ! -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
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ Run `bin/fm-public-followup.sh --help` for the exact subcommands and flags.
Registration is what creates this home's private transport under `state/public-followup/` (mode 0700): `registry/` for the bounded private binding of each open public loop (the record survives delivery, stamped `state=delivered`, and is removed only by `retire`), `events/` for typed terminal results awaiting reconciliation, `consumed/` for the accepted-event ledger, `rejected/` for refusals kept with a one-line reason, `retired/` for the mode-0600 reason-and-time receipt written before removal, and `surfaced` for the poll's last-surfaced signature.
The home that owns the commitment also owns the outward post, because only it holds the relay consent, the request context, and the opaque thread binding.
Work routed elsewhere reports a typed terminal result with `bin/fm-public-followup-emit.sh` and never looks for the thread; that emitter refuses to write into a home with no registration for the named obligation.
When that work lives in a REMOTE secondmate home, delivering or retiring the loop first clears its bound legacy link over that route's SSH transport only when the remote link's Relay request identity matches the registration, so closing such a loop needs a reachable host with a Firstmate copy that supports the guarded clear; an identity mismatch, an unverifiable identity, an older remote copy, or a host that never confirms the clear leaves the loop retained for reconciliation rather than closed here.
A terminal event's id is derived from its identity tuple, so a duplicate report, a retry, or a replay after restart resolves to the same event and changes nothing.

Activation is the same `.env` `FMX_PAIRING_TOKEN` contract as the rest of Relay, with no second flag.
Expand Down
Loading
Loading