diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 4b8e4b0e96..d2aac94fb2 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -231,14 +231,17 @@ So treat second-mate-routed Relay work as a promised final by construction: the **When you promise a final (including every Relay request whose work is routed to a second mate):** 1. Create the typed obligation with `tasks-axi public-followup add` and bind the work with `bind-work`, keeping the public-safe summary and the opaque thread binding in the obligation and the full request context where the poll already put it. + When the public ask plainly implies follow-on work ("look into X and fix it"), register the promised-final against the outcome and deliver any interim report as a separate `--purpose milestone` obligation on the same thread. + An ask that genuinely terminates at a report stays `report-ready`; do not invent a ship commitment for work the captain has not authorized. 2. Register it with `bin/fm-public-followup.sh register --relation --work-home > --work-id --generation `. This is what makes the commitment reconcilable without you. 3. Put `bin/fm-public-followup.sh brief ` output straight into the worker's brief. - It prints the exact reporting command for that binding. - When the work is routed to a second mate rather than spawned here, the routed item's own note carries that same output, so it survives the routing and reaches whoever ends up doing the work. + It prints the exact reporting command for that binding, including the obligation's actual required deliverable keys. + When the work is routed to a second mate rather than spawned here, the routed item's own note MUST carry that same `brief` output so it survives the routing and reaches whoever ends up doing the work. + A header-only routed item loses the emit command. Never ask a worker to find the thread or post the reply: only this home holds the relay consent and the thread binding. -**When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment:** +**When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment or an open public loop:** 1. Run `bin/fm-public-followup.sh consume`. It reconciles every typed terminal result from disk and prints `ready ` for each commitment that became deliverable. @@ -246,16 +249,27 @@ So treat second-mate-routed Relay work as a promised final by construction: the 2. For each ready commitment, run `bin/fm-public-followup.sh deliver `. With no `--text-file` it reuses the accepted terminal outcome exactly, which is the preferred path for a landed result. Only pass `--text-file` when the outcome genuinely needs composing, and hold it to the same public-safety bar as every other reply here. - Delivery clears the bound task's legacy Relay link at the validated receipt boundary; if it reports a cleanup failure, use its reconciliation message and do not post a legacy final. + Delivery clears the bound task's legacy Relay link at the validated receipt boundary and stamps the registration `state=delivered`; it does **not** close the public loop. + If it reports a cleanup failure, use its reconciliation message and do not post a legacy final. 3. Read the outcome and stop guessing at anything it refuses: - "still waiting on its bound work" means the work has not reported a typed terminal result yet - do not post. - "recorded as retryable" means nothing was posted; retry on a later wake. - "held" means the thread's platform or budget is unresolvable right now; retry once it is recoverable. - - "mid-delivery" means a previous post started and its outcome was never recorded. Do NOT deliver again. Establish whether that post landed, then either close it with `record-posted --attempt --chunks ` or escalate. Posting again would put a second reply in a public thread. + - "mid-delivery" means a previous post started and its outcome was never recorded. + Do NOT deliver again. + Establish whether that post landed, then either record its receipt with `record-posted --attempt --chunks ` or escalate. + Posting again would put a second reply in a public thread. - "the relay no longer accepts a follow-up" is a captain decision, not a retry. +4. After a successful deliver (or when the digest lists an `open-loop` line), decide the disposition in that same turn: + - Follow-on work authorized from the same public thread: `bin/fm-public-followup.sh rechain --from --work-home > --work-id --expected `, then put the printed `brief` into that follow-on's instructions (and into the routed item's own note when the work is routed). + If rechain reports an interrupted bind or source-retirement failure, resume the same destination with the same command; the retained source claim forbids choosing another destination. + - The public loop is finished: `bin/fm-public-followup.sh retire --reason ""`. + Delivering a final is not closure. + Silence after delivery is an open loop, not a kept promise for later work. Cleanup refuses while a commitment is still owed for that exact work, so never reach for `--force` to get past it. Treat a commitment as kept only after a validated posted receipt or an explicit captain waiver. +Treat a public loop as closed only after `retire`. ## Notes diff --git a/AGENTS.md b/AGENTS.md index 83aa105a4f..d43009911b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ state/ runtime records and signals; gitignored x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated Relay dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) - public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) + public-followup/ generated private transport for promised public replies: retained open-loop registrations, typed terminal-result inbox, accepted/rejected ledgers, and retirement receipts (section 14; bin/fm-public-followup.sh) x-poll.error x-poll.claim-error generated Relay and offer-claim diagnostic dedupe markers .startup-network.* status, report, per-step elapsed timings, inline-print claim, and lock for the deferred network stage session start runs off its blocking path; bin/fm-startup-network.sh .wake-queue durable queued wakes retained until post-handling acknowledgement: epochseqkindkeypayload @@ -555,7 +555,7 @@ On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respo For every Relay-linked terminal outcome, load that owner and use the promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up before teardown. A promised final public reply is durable state, never conversation memory. -Load `fmx-respond` before promising one, on a `public-followup ...` check wake, and whenever the session-start digest lists a public commitment awaiting delivery. +Load `fmx-respond` before promising one, on a `public-followup ...` check wake, and whenever the session-start digest lists a public commitment awaiting delivery or an open public loop. Only the home holding the relay consent and thread binding ever posts it, so never ask a secondmate or crewmate to find the thread or send the reply, and never recover a terminal result by reading a `done:` sentence. ## Captain instruction precedence diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index f773655334..97bda75c33 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -26,9 +26,10 @@ # already present in the secondmate backlog is reported and skipped, and if # any key matches neither backlog nothing is moved; # - warning, after a successful move, when a moved key still owes a public -# relay reply bound to main/, because that binding no longer names the -# home that owns the work. The move is not blocked: rebinding the commitment -# to secondmate: is a relay-side decision the caller makes. +# relay reply bound to main/, or when this home has an open public loop +# with nothing owed, because routing work out does not close that loop. The +# move is not blocked: rebinding or rechain is a relay-side decision the +# caller makes. # # What `tasks-axi mv ... --to ` owns: moving each full item BLOCK # byte-exact (header, body lines, blank separators, and indented pseudo-headings @@ -58,6 +59,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" REG="$DATA/secondmates.md" MAIN_BACKLOG="$DATA/backlog.md" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 @@ -66,6 +68,8 @@ MAIN_BACKLOG="$DATA/backlog.md" . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" ACTIVE_HANDOFF_LOCK= ACTIVE_REGISTRY_LOCK= @@ -288,6 +292,10 @@ warn_stale_public_commitments() { # ... printf 'warning: %s still owes a public reply bound to main/%s; rebind it to secondmate:%s (tasks-axi public-followup bind-work, then bin/fm-public-followup.sh register --relation --work-home secondmate:%s --work-id %s --generation ) or the promised reply will be reconciled against work this home no longer owns.\n' \ "$key" "$key" "$id" "$id" "$key" >&2 done + if fm_pf_relay_active "$FM_HOME" && fm_pf_has_delivered_open_loops "$STATE"; then + printf 'warning: this home has an open public loop with nothing owed; routing work to secondmate:%s does not close it. Hand it on with bin/fm-public-followup.sh rechain or close it with retire --reason.\n' \ + "$id" >&2 + fi # Reporting never changes the handoff's own success: the move already landed. return 0 } diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 0ed1fd0616..7e844f942e 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -24,6 +24,12 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" MODE= YOLO= @@ -123,3 +129,105 @@ META_LOCK_HELD=0 HOME_Q=$(printf '%q' "$FM_HOME") echo "promoted $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" echo "next: FM_HOME=$HOME_Q bin/fm-send.sh fm-$ID ''" + +promote_print_rechain_hint() { + local consent_home=$1 work_home=$2 task_id=$3 id prefix + prefix= + [ "$consent_home" = "$FM_HOME" ] || prefix="FM_HOME=$(printf '%q' "$consent_home") " + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$consent_home/state" "$id" state)" = delivered ] || continue + echo "next: ${prefix}bin/fm-public-followup.sh rechain --from $id --work-home $work_home --work-id $task_id --expected pr-merged" + done </dev/null && pwd -P +} + +promote_resolve_primary_home() { + local parent=$1 child=$2 mate_id=$3 parent_meta registry meta_home + fm_pf_home_id_valid "secondmate:$mate_id" || return 1 + parent=$(promote_canonical_home "$parent") || return 1 + child=$(promote_canonical_home "$child") || return 1 + [ "$parent" != "$child" ] || return 1 + parent_meta="$parent/state/$mate_id.meta" + [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 + [ "$(fmx_meta_get "$parent_meta" kind)" = secondmate ] || return 1 + meta_home=$(fmx_meta_get "$parent_meta" home) + meta_home=$(CDPATH='' cd -- "$meta_home" 2>/dev/null && pwd -P) || return 1 + [ "$meta_home" = "$child" ] || return 1 + registry="$parent/data/secondmates.md" + secondmate_registry_validate_bindings "$registry" secondmate_registry_path_key \ + "$mate_id" "$child" || return 1 + printf '%s\n' "$parent" +} + +promote_warn_parent_unresolved() { + echo "warning: could not resolve the consent-holding parent home for secondmate $1; promotion succeeded, but any open public loop must be inspected and rechained from the parent." >&2 +} + +if [ -f "$FM_HOME/.fm-secondmate-home" ]; then + PROMOTE_MATE_ID=$(sed -n '1p' "$FM_HOME/.fm-secondmate-home" 2>/dev/null || true) + PROMOTE_PARENT_RECORD=absent + PROMOTE_PARENT_ROUTE= + PROMOTE_DURABLE_PARENT= + if [ -e "$FM_HOME/.fm-secondmate-parent" ] || [ -L "$FM_HOME/.fm-secondmate-parent" ]; then + PROMOTE_PARENT_RECORD=invalid + if fm_secondmate_parent_record_parse "$FM_HOME/.fm-secondmate-parent"; then + PROMOTE_PARENT_RECORD=valid + PROMOTE_PARENT_ROUTE=$FM_SECONDMATE_PARENT_ROUTE + PROMOTE_DURABLE_PARENT=$FM_SECONDMATE_PARENT_HOME + fi + fi + if [ "$PROMOTE_PARENT_RECORD" = invalid ]; then + promote_warn_parent_unresolved "$PROMOTE_MATE_ID" + elif [ "$PROMOTE_PARENT_ROUTE" = local ]; then + PROMOTE_PARENT_CANDIDATE=${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-$PROMOTE_DURABLE_PARENT} + PROMOTE_PARENT_BINDINGS_MATCH=1 + if [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then + PROMOTE_LIVE_PARENT=$(promote_canonical_home "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME") \ + || PROMOTE_PARENT_BINDINGS_MATCH=0 + PROMOTE_RECORDED_PARENT=$(promote_canonical_home "$PROMOTE_DURABLE_PARENT") \ + || PROMOTE_PARENT_BINDINGS_MATCH=0 + if [ "$PROMOTE_PARENT_BINDINGS_MATCH" = 1 ] \ + && [ "$PROMOTE_LIVE_PARENT" != "$PROMOTE_RECORDED_PARENT" ]; then + PROMOTE_PARENT_BINDINGS_MATCH=0 + fi + fi + if [ "$PROMOTE_PARENT_BINDINGS_MATCH" = 1 ] \ + && PROMOTE_PARENT=$(promote_resolve_primary_home \ + "$PROMOTE_PARENT_CANDIDATE" "$FM_HOME" "$PROMOTE_MATE_ID"); then + if fm_pf_relay_active "$PROMOTE_PARENT"; then + promote_print_rechain_hint "$PROMOTE_PARENT" "secondmate:$PROMOTE_MATE_ID" "$ID" + fi + else + promote_warn_parent_unresolved "$PROMOTE_MATE_ID" + fi + elif [ "$PROMOTE_PARENT_ROUTE" = remote ]; then + PROMOTE_HOME_ENV_TOKEN= + if [ -f "$FM_HOME/.env" ]; then + PROMOTE_HOME_ENV_TOKEN=$(fmx_env_get FMX_PAIRING_TOKEN "$FM_HOME/.env") + fi + if [ -n "$PROMOTE_HOME_ENV_TOKEN" ]; then + promote_warn_parent_unresolved "$PROMOTE_MATE_ID" + fi + elif [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then + if fm_pf_relay_active "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME"; then + if PROMOTE_PARENT=$(promote_resolve_primary_home \ + "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME" "$FM_HOME" "$PROMOTE_MATE_ID"); then + promote_print_rechain_hint "$PROMOTE_PARENT" "secondmate:$PROMOTE_MATE_ID" "$ID" + else + promote_warn_parent_unresolved "$PROMOTE_MATE_ID" + fi + fi + elif fm_pf_relay_active "$FM_HOME"; then + promote_warn_parent_unresolved "$PROMOTE_MATE_ID" + fi +elif fm_pf_relay_active "$FM_HOME"; then + promote_print_rechain_hint "$FM_HOME" main "$ID" +fi diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index dc7153d53c..20ebd372d7 100644 --- a/bin/fm-public-followup-lib.sh +++ b/bin/fm-public-followup-lib.sh @@ -5,9 +5,10 @@ # Firstmate promises a public final reply when a myfirstmate relay mention (X or # Discord) asks for work. `tasks-axi public-followup` is the sole owner of that # typed obligation and its state machine; state/x-context/ is the sole owner of -# the private full request context. This library owns only the small Firstmate -# side: the activation gate, the private per-home transport directories, and the -# deterministic terminal-event identity. +# the private full request context. This library owns Firstmate's activation +# gate, private per-home transport paths, retained-loop state and locking +# helpers, follow-up window classification, and deterministic terminal-event +# identity. # # Sourced, never executed. No side effects on source (it creates nothing), which # is what keeps a relay-disabled home free of public-followup artifacts. @@ -21,17 +22,26 @@ # [ -f ] test and nothing else runs. # 2. fm_pf_has_registrations O(1) presence check on the registry created # / fm_pf_has_events only by the relay path (fm-public-followup.sh -# register). Relay-enabled homes with no -# public commitments stop here, so no -# tasks-axi call and no backlog scan happens. +# / fm_pf_has_open_loops register). Open loops ARE registrations: +# a delivered final keeps the record, so this +# same check is the fail-loud session-start +# gate. Relay-enabled homes with no public +# loops stop here, so no tasks-axi call and +# no backlog scan happens. # # Private transport layout, all under /state/public-followup (mode 0700, -# created only by `fm-public-followup.sh register`): -# registry/ registration record: the bounded public-safe -# binding (obligation, relation, work ref, -# generation, platform, request id). Presence hint -# and reverse work->obligation index only; the -# obligation itself always remains tasks-axi truth. +# initialized by `fm-public-followup.sh register` and extended only by these +# public-followup commands): +# registry/ registration record: the bounded private binding +# (obligation, relation, work ref and canonical +# secondmate path, generation, platform, request id) +# plus the loop fields that survive delivery (state, +# delivered_at, followup_expires_at, +# request_context_b64). Presence means the public +# loop is still open. Delivery +# stamps state=delivered; only `retire` removes the +# record. The obligation itself always remains +# tasks-axi truth. # events/.json inbound typed terminal events awaiting # reconciliation, one file per event id. # consumed/ idempotency ledger: an accepted event id is never @@ -43,6 +53,10 @@ # surfaced last surfaced pending-event signature, so the # existing relay poll wakes once per new event set # instead of every cycle. +# retired/ private retirement receipt containing the bounded +# reason and timestamp recorded before the registry +# entry is removed; its presence prevents replayed +# registration from reopening the closed loop. # # Event identity is DERIVED, never random: fm_pf_event_id hashes the canonical # identity tuple, so re-emitting the same terminal result produces the same @@ -91,6 +105,13 @@ fm_pf_registry_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/registry"; } fm_pf_events_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/events"; } fm_pf_consumed_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/consumed"; } fm_pf_rejected_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/rejected"; } +fm_pf_retired_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/retired"; } + +fm_pf_retirement_receipt_exists() { + local file + file="$(fm_pf_retired_dir "$1")/$2" + [ -f "$file" ] && [ ! -L "$file" ] +} # fm_pf_dir_has_entry : 0 when is a real directory holding at least # one non-dot entry. Stops at the first hit, so cost does not grow with the @@ -107,6 +128,11 @@ fm_pf_dir_has_entry() { fm_pf_has_registrations() { fm_pf_dir_has_entry "$(fm_pf_registry_dir "$1")"; } fm_pf_has_events() { fm_pf_dir_has_entry "$(fm_pf_events_dir "$1")"; } +# Every retained registration is an open public loop (owed or delivered). Same +# O(1) directory presence check as fm_pf_has_registrations; the name is the +# post-retention semantic so callers do not treat "a reply is owed" as the +# only reason a record exists. +fm_pf_has_open_loops() { fm_pf_has_registrations "$1"; } # fm_pf_active : both gates, in order. The single predicate every # caller outside the relay path should use before doing any public-followup work. @@ -224,6 +250,131 @@ $(fm_pf_registry_ids "$state") EOF } +# fm_pf_now_epoch: wall clock as epoch seconds. FMX_NOW_OVERRIDE pins it for +# tests, matching bin/fm-x-lib.sh. +fm_pf_now_epoch() { + printf '%s\n' "${FMX_NOW_OVERRIDE:-$(date +%s)}" +} + +# fm_pf_now_rfc3339: UTC timestamp for delivered_at and similar stamps. +fm_pf_now_rfc3339() { + local epoch + epoch=$(fm_pf_now_epoch) + 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 \ + || date -u +%Y-%m-%dT%H:%M:%SZ +} + +# fm_pf_rfc3339_to_epoch : parse a Zulu timestamp. Empty on failure. +fm_pf_rfc3339_to_epoch() { + local ts=$1 + [ -n "$ts" ] || return 1 + date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null \ + || date -u -d "$ts" +%s 2>/dev/null \ + || return 1 +} + +# fm_pf_followup_window_class : ok, closing (<48h), expired, or unknown. +fm_pf_followup_window_class() { + local ts=$1 exp now + exp=$(fm_pf_rfc3339_to_epoch "$ts") || { printf 'unknown\n'; return 0; } + now=$(fm_pf_now_epoch) + if [ "$now" -ge "$exp" ]; then + printf 'expired\n' + elif [ $((exp - now)) -lt 172800 ]; then + printf 'closing\n' + else + printf 'ok\n' + fi +} + +# fm_pf_b64_encode: stdin to a single-line base64 payload (no wrapping). +fm_pf_b64_encode() { + base64 2>/dev/null | tr -d '\n\r' +} + +# fm_pf_b64_decode: stdin (single-line or wrapped base64) to bytes on stdout. +fm_pf_b64_decode() { + local data + data=$(cat) + printf '%s\n' "$data" | base64 -d 2>/dev/null \ + || printf '%s\n' "$data" | base64 -D 2>/dev/null +} + +# fm_pf_registry_loop_state : open or delivered. A pre-change +# record with no state= is treated as open so live homes never crash. +fm_pf_registry_loop_state() { + local v + v=$(fm_pf_registry_get "$1" "$2" state) + case "$v" in + delivered) printf 'delivered\n' ;; + *) printf 'open\n' ;; + esac +} + +# fm_pf_registry_rechainable : 0 when request_context_b64 is present. +fm_pf_registry_rechainable() { + local ctx + ctx=$(fm_pf_registry_get "$1" "$2" request_context_b64) + [ -n "$ctx" ] +} + +# fm_pf_has_delivered_open_loops : 0 when any retained record is +# state=delivered (an open loop with nothing owed). Pre-change records have no +# state= and are treated as still-owed, not delivered. +fm_pf_has_delivered_open_loops() { + local state=$1 id + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" state)" = delivered ] || continue + return 0 + done </dev/null || return 1 + if ! command -v fm_lock_acquire_wait >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$_FM_PF_LIB_DIR/fm-wake-lib.sh" + fi + fm_lock_acquire_wait "$(fm_pf_registry_lock_path "$state" "$id")" +} + +fm_pf_registry_lock_release() { + fm_lock_release "$(fm_pf_registry_lock_path "$1" "$2")" +} + +# fm_pf_registry_stamp_delivered : rewrite one record +# with state=delivered and delivered_at, keeping every other field. The record +# stays; only retire removes it. +fm_pf_registry_stamp_delivered() { + local state=$1 id=$2 delivered_at=$3 file rest rc=0 + fm_pf_slug_valid "$id" || return 1 + [ -n "$delivered_at" ] || return 1 + fm_pf_registry_lock_acquire "$state" "$id" || return 1 + file="$(fm_pf_registry_dir "$state")/$id" + if [ -f "$file" ] && [ ! -L "$file" ]; then + rest=$(grep -v -E '^(state|delivered_at|delivered_obligation)=' "$file" 2>/dev/null || true) + printf '%s\nstate=delivered\ndelivered_at=%s\ndelivered_obligation=%s\n' \ + "$rest" "$delivered_at" "$id" \ + | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$state")" "$id" 600 \ + || rc=$? + else + rc=3 + fi + fm_pf_registry_lock_release "$state" "$id" + return "$rc" +} + # --- pending-event signature ------------------------------------------------ # Consumed by the sourcing scripts, not by this library. diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index caa6cc44aa..dda567cca1 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -27,7 +27,8 @@ # Usage: # fm-public-followup.sh active # Silent gate probe. Exit 0 when this home has live public-followup work -# worth looking at, 1 otherwise. Safe to call unconditionally. +# worth looking at, including a delivered open loop, 1 otherwise. Safe to +# call unconditionally. # # fm-public-followup.sh register --relation # --work-home > --work-id --generation @@ -42,7 +43,8 @@ # fm-public-followup.sh brief # Print the exact fm-public-followup-emit.sh command line the bound worker # must run when its work reaches the promised terminal outcome, so the -# binding is copied into a brief instead of hand-assembled. +# binding is copied into a brief instead of hand-assembled. The +# --deliverable flags name the obligation's actual required keys. # # fm-public-followup.sh consume # Drain every pending typed terminal event: validate its derived identity, @@ -54,43 +56,60 @@ # replay are no-ops. # # fm-public-followup.sh pending -# One bounded public-safe line per unresolved commitment, for the session -# start digest. Prunes registrations whose obligation is already closed. -# Silent when nothing is unresolved. +# One bounded public-safe line per open public loop, for the session +# start digest. Unresolved commitments print as "unresolved" (a reply is +# still owed). Delivered or settled registrations print as "open-loop" +# (the thread is still open with nothing owed). Registrations are never +# pruned here; only `retire` removes one. Silent when nothing is open. # # fm-public-followup.sh deliver [--text-file ] -# Post the final public reply into the ORIGINAL thread and close the -# obligation. Uses the stored platform and opaque context binding, so the -# destination is never guessed. Without --text-file the accepted terminal -# event's bounded public-safe outcome is reused exactly, which keeps the -# common path deterministic. The sequence is begin-delivery with the -# payload hash, post, then record the posted receipt or a typed error. -# A validated receipt also clears any bound legacy X link before the -# registration is removed. -# An already-posted obligation is an idempotent success without another -# post; an obligation left in delivery-posting by a crash is REFUSED -# rather than posted again. +# Post the final public reply into the ORIGINAL thread. Uses the stored +# platform and opaque context binding, so the destination is never guessed. +# Without --text-file the accepted terminal event's bounded public-safe +# outcome is reused exactly, which keeps the common path deterministic. +# The sequence is begin-delivery with the payload hash, post, then record +# the posted receipt or a typed error. A validated receipt also clears any +# bound legacy X link, then stamps the registration state=delivered. Delivery +# does not close the public loop; `retire` is the only close. Prints a +# disposition line so the loop is handed on with `rechain` or closed +# explicitly. An already-posted obligation is an idempotent success +# without another post; an obligation left in delivery-posting by a crash +# is REFUSED rather than posted again. # # fm-public-followup.sh record-posted --attempt --chunks -# Close an obligation whose post is known to have landed on exactly +# Record an obligation whose post is known to have landed on exactly # attempt with exactly messages, without posting anything. This is # the late-receipt path: use it when a post succeeded but its receipt was -# lost, never to paper over an unknown outcome. +# lost, never to paper over an unknown outcome. Stamps the registration +# delivered; does not remove it. # # fm-public-followup.sh guard-work # Exit 3 when this home has an unresolved public commitment bound to that # exact work, printing one line per blocking obligation. Exit 0 otherwise. # Cleanup paths call this so bound work is never treated as finished while -# its public promise is still open. +# its public promise is still open. A delivered registration is not a +# block: that work's reply already landed. # -# fm-public-followup.sh retire [--force] -# Drop the registration once its obligation is closed. --force is the -# explicit discard-approved escape hatch for an unresolved or missing -# obligation. +# fm-public-followup.sh rechain --from +# --work-home > --work-id +# --expected +# [--deliverable-key ]... +# Hand a delivered public loop on to follow-on work against the same +# thread. Decodes the retained request context, creates and binds a fresh +# promised-final obligation, registers it, retires the source with reason +# "handed on to ", and prints `brief` for the new obligation. +# Refuses unless the source is state=delivered, the follow-up window is +# still open, and the relay is active. A pre-change record without +# request_context_b64 is un-rechainable. # -# Requires jq and a compatible tasks-axi for registration, reconciliation, -# delivery, cleanup guards, and retirement; `active` and `brief` only inspect -# local state. +# fm-public-followup.sh retire --reason "" [--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. +# +# Requires jq and a compatible tasks-axi for registration, briefs, +# reconciliation, delivery, cleanup guards, and retirement; only `active` +# inspects local state alone. # FM_PF_RETRY_BACKOFF_SECS (default 900) sets the next-attempt time recorded with # a retryable delivery error. set -u @@ -110,7 +129,7 @@ RETRY_BACKOFF=${FM_PF_RETRY_BACKOFF_SECS:-900} case "$RETRY_BACKOFF" in ''|*[!0-9]*) RETRY_BACKOFF=900 ;; esac usage() { - echo "usage: fm-public-followup.sh [args]" >&2 + echo "usage: fm-public-followup.sh [args]" >&2 } # The header comment IS the help text, so the two can never drift apart. @@ -119,12 +138,40 @@ help() { sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//'; } die() { printf 'fm-public-followup: %s\n' "$1" >&2; exit "${2:-2}"; } PF_TEMP_FILES=() -pf_cleanup_temp_files() { +PF_REGISTRY_LOCK_IDS=() +pf_registry_lock_held() { + local wanted=$1 held + for held in "${PF_REGISTRY_LOCK_IDS[@]}"; do + [ "$held" = "$wanted" ] && return 0 + done + return 1 +} +pf_registry_lock_acquire() { + local id=$1 + pf_registry_lock_held "$id" && return 0 + fm_pf_registry_lock_acquire "$STATE" "$id" || return 1 + PF_REGISTRY_LOCK_IDS+=("$id") +} +pf_registry_lock_release() { + local id=$1 held + local -a remaining=() + pf_registry_lock_held "$id" || return 0 + fm_pf_registry_lock_release "$STATE" "$id" + for held in "${PF_REGISTRY_LOCK_IDS[@]}"; do + [ "$held" = "$id" ] || remaining+=("$held") + done + PF_REGISTRY_LOCK_IDS=("${remaining[@]}") +} +pf_cleanup() { + local i + for ((i=${#PF_REGISTRY_LOCK_IDS[@]}-1; i>=0; i--)); do + fm_pf_registry_lock_release "$STATE" "${PF_REGISTRY_LOCK_IDS[$i]}" 2>/dev/null || true + done [ "${#PF_TEMP_FILES[@]}" -eq 0 ] || rm -f -- "${PF_TEMP_FILES[@]}" } -trap pf_cleanup_temp_files EXIT +trap pf_cleanup EXIT -now_rfc3339() { date -u +%Y-%m-%dT%H:%M:%SZ; } +now_rfc3339() { fm_pf_now_rfc3339; } # next_attempt_rfc3339: the retry time recorded with a retryable delivery error. # BSD and GNU date disagree on the flag, so try both and print nothing when @@ -233,17 +280,48 @@ cmd_register() { [ -n "$request" ] || request=$(pf_field "$payload" '.public_followup.request.request_id') [ -z "$request" ] || fm_pf_slug_valid "$request" || die "unsafe request id: $request" - local mkdir_target + local followup_expires_at request_json request_context_b64 work_home_path + followup_expires_at=$(pf_field "$payload" '.public_followup.request.followup_expires_at') + request_json=$(printf '%s' "$payload" | jq -c '.public_followup.request // empty' 2>/dev/null || true) + request_context_b64= + if [ -n "$request_json" ]; then + request_context_b64=$(printf '%s' "$request_json" | fm_pf_b64_encode) + fi + work_home_path= + case "$work_home" in + secondmate:*) + work_home_path=$(public_followup_secondmate_home "${work_home#secondmate:}" 2>/dev/null || true) + case "$work_home_path" in + *$'\n'*|*$'\r'*) work_home_path= ;; + esac + ;; + esac + + local mkdir_target registry_state retired_file for mkdir_target in "$(fm_pf_registry_dir "$STATE")" "$(fm_pf_events_dir "$STATE")" \ "$(fm_pf_consumed_dir "$STATE")" "$(fm_pf_rejected_dir "$STATE")"; do fmx_private_artifact_dir_prepare "$mkdir_target" >/dev/null \ || die "could not prepare $mkdir_target" 1 done - printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\n' \ - "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ + pf_registry_lock_acquire "$id" \ + || die "could not lock registration '$id'" 1 + retired_file="$(fm_pf_retired_dir "$STATE")/$id" + if [ -e "$retired_file" ] || [ -L "$retired_file" ]; then + die "public loop '$id' has already been retired and cannot be registered again" 1 + fi + registry_state=$(fm_pf_registry_loop_state "$STATE" "$id") + if [ "$registry_state" = delivered ]; then + pf_registry_lock_release "$id" + printf 'already registered %s state=delivered\n' "$id" + return 0 + fi + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_home_path=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\nstate=open\nfollowup_expires_at=%s\nrequest_context_b64=%s\n' \ + "$id" "$relation" "$work_home" "$work_home_path" "$work_id" "$generation" "$platform" "$request" \ + "$followup_expires_at" "$request_context_b64" \ | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ || die "could not write the registration record" 1 + pf_registry_lock_release "$id" printf 'registered %s %s/%s generation=%s platform=%s\n' \ "$id" "$work_home" "$work_id" "$generation" "${platform:-unknown}" @@ -252,7 +330,7 @@ cmd_register() { # --- subcommand: brief ------------------------------------------------------ cmd_brief() { - local id=${1:-} relation work_home work_id generation + local id=${1:-} relation work_home work_id generation payload outcome keys key deliverable_flags [ -n "$id" ] || { usage; exit 2; } fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 @@ -264,6 +342,29 @@ cmd_brief() { work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) generation=$(fm_pf_registry_get "$STATE" "$id" generation) + require_tools + payload=$(obligation_json "$id") \ + || die "could not read public-followup obligation '$id' through tasks-axi" 1 + [ -n "$payload" ] \ + || die "public-followup obligation '$id' is missing from tasks-axi" 1 + outcome=$(pf_field "$payload" '.public_followup.expected_final.type') + [ -n "$outcome" ] \ + || die "public-followup obligation '$id' has no expected final type" 1 + keys=$(printf '%s' "$payload" \ + | jq -er '.public_followup.expected_final.required_deliverables + | select(type == "array" and length > 0 + and (map(type == "string" and test("^[a-z0-9_]+$")) | all)) + | .[]' 2>/dev/null) \ + || die "public-followup obligation '$id' has no readable required deliverable keys" 1 + deliverable_flags= + while IFS= read -r key; do + [ -n "$key" ] || continue + deliverable_flags="${deliverable_flags} --deliverable ${key}= \\ +" + done < \\ - --deliverable = \\ - --outcome-text '' + --outcome $outcome \\ +${deliverable_flags} --outcome-text '' Do not post anything publicly yourself and do not look for the public thread: the home above owns the reply. @@ -426,10 +526,52 @@ cmd_consume() { # --- subcommand: pending ---------------------------------------------------- +# print_open_loop : the session-start line for a public loop that +# is still open after delivery (or whose obligation has left the backlog). +print_window_escalation() { + local expires=$1 window + window=$(fm_pf_followup_window_class "$expires") + case "$window" in + expired) + printf ' DEADLINE: thread can no longer be reached (window closed %s); this needs a captain decision\n' \ + "${expires:-unknown}" + ;; + closing) + printf ' DEADLINE: window closes %s (under 48 hours)\n' "${expires:-unknown}" + ;; + esac +} + +print_open_loop() { + local id=$1 payload=$2 request platform summary delivered expires ctx + request=$(fm_pf_registry_get "$STATE" "$id" request_id) + [ -n "$request" ] || request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(fm_pf_registry_get "$STATE" "$id" platform) + [ -n "$platform" ] || platform=$(pf_field "$payload" '.public_followup.request.platform') + delivered=$(fm_pf_registry_get "$STATE" "$id" delivered_at) + expires=$(fm_pf_registry_get "$STATE" "$id" followup_expires_at) + [ -n "$expires" ] || expires=$(pf_field "$payload" '.public_followup.request.followup_expires_at') + summary=$(pf_field "$payload" '.public_followup.request.public_safe_summary' | fm_pf_clean_outcome_text) + if [ -z "$summary" ]; then + ctx=$(fm_pf_registry_get "$STATE" "$id" request_context_b64) + if [ -n "$ctx" ]; then + summary=$(printf '%s' "$ctx" | fm_pf_b64_decode | jq -r '.public_safe_summary // empty' 2>/dev/null | fm_pf_clean_outcome_text) + fi + fi + printf 'open-loop %s request=%s platform=%s\n' "$id" "${request:-unknown}" "${platform:-unknown}" + printf ' delivered=%s window-closes=%s\n' "${delivered:-unknown}" "${expires:-unknown}" + printf ' summary=%s\n' "$summary" + if ! fm_pf_registry_rechainable "$STATE" "$id"; then + printf ' unrechainable: pre-change registration lacks request_context_b64\n' + fi + print_window_escalation "$expires" + printf ' -> bind the follow-on with rechain, or close the loop with retire %s --reason ...\n' "$id" +} + cmd_pending() { gate_or_exit - local listing id payload delivery task_state summary platform request printed=0 + local listing id payload delivery task_state summary platform request expires printed=0 loop_state settled stamp_rc # An unreadable backlog with registrations present is exactly the silence this # whole path exists to prevent, so say so rather than printing nothing. if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1 \ @@ -460,26 +602,32 @@ cmd_pending() { [ -n "$id" ] || continue payload=$(printf '%s' "$listing" | jq -ce --arg id "$id" \ '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null) - if [ -z "$payload" ]; then - # The obligation is gone from the backlog (pruned after Done): the - # registration is stale bookkeeping, not evidence, so drop it. - if ! clear_public_followup_link "$id"; then - printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" - printed=1 - continue - fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true - continue - fi + loop_state=$(fm_pf_registry_loop_state "$STATE" "$id") delivery=$(pf_field "$payload" '.public_followup.delivery.state') task_state=$(pf_field "$payload" '.state') - if [ "$task_state" = 'done' ] || [ "$delivery" = 'posted' ] || [ "$delivery" = 'waived' ]; then - if ! clear_public_followup_link "$id"; then - printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" - printed=1 - continue + settled=0 + if [ -z "$payload" ] || [ "$task_state" = 'done' ] \ + || [ "$delivery" = 'posted' ] || [ "$delivery" = 'waived' ] \ + || [ "$loop_state" = delivered ]; then + settled=1 + fi + if [ "$settled" -eq 1 ]; then + if [ "$loop_state" != delivered ]; then + stamp_rc=0 + fm_pf_registry_stamp_delivered "$STATE" "$id" "$(now_rfc3339)" || stamp_rc=$? + if [ "$stamp_rc" -eq 3 ] && fm_pf_retirement_receipt_exists "$STATE" "$id"; then + continue + fi + [ "$stamp_rc" -eq 0 ] \ + || die "could not stamp settled registration '$id' as delivered" 1 fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + # Keep the registration. Clearing a leftover legacy link is best-effort + # and never the close; only retire removes the record. + if public_followup_registration_valid "$id"; then + clear_public_followup_link "$id" >/dev/null 2>&1 || true + fi + print_open_loop "$id" "$payload" + printed=1 continue fi summary=$(pf_field "$payload" '.public_followup.request.public_safe_summary' | fm_pf_clean_outcome_text) @@ -487,6 +635,12 @@ cmd_pending() { request=$(pf_field "$payload" '.public_followup.request.request_id') printf 'unresolved %s state=%s platform=%s request=%s summary=%s\n' \ "$id" "${delivery:-unknown}" "${platform:-unknown}" "${request:-unknown}" "$summary" + expires=$(fm_pf_registry_get "$STATE" "$id" followup_expires_at) + [ -n "$expires" ] || expires=$(pf_field "$payload" '.public_followup.request.followup_expires_at') + print_window_escalation "$expires" + if ! fm_pf_registry_rechainable "$STATE" "$id"; then + printf ' unrechainable: pre-change registration lacks request_context_b64\n' + fi printed=1 done </dev/null && pwd -P) || return 1 - [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 1 + meta_home=$(fmx_meta_get "$STATE/$id.meta" home) + registry_home= + if [ -f "$DATA/secondmates.md" ] && [ ! -L "$DATA/secondmates.md" ]; then + registry_home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home || true) + fi + if [ -n "$meta_home" ] && [ -n "$registry_home" ] && [ "$meta_home" != "$registry_home" ]; then + return 2 + fi + home=${meta_home:-$registry_home} + [ -n "$home" ] || return 4 + case "$home" in /*) ;; *) return 2 ;; esac + if [ ! -e "$home" ]; then + [ ! -L "$home" ] || return 2 + [ "$include_absent" = include-absent ] && printf '%s\n' "$home" + return 3 + fi + home=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 2 + [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 2 marker=$(sed -n '1p' "$home/.fm-secondmate-home" 2>/dev/null) - [ "$marker" = "$id" ] || return 1 + [ "$marker" = "$id" ] || return 2 printf '%s\n' "$home" } clear_public_followup_link() { - local id=$1 work_home work_id home state + local id=$1 work_home work_home_path work_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) @@ -546,7 +709,22 @@ clear_public_followup_link() { state=$STATE ;; secondmate:*) - home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 1 + 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 + rc=0 + home=$(public_followup_secondmate_home "${work_home#secondmate:}" include-absent) || rc=$? + if [ "$rc" -eq 3 ]; then + [ "$home" = "$work_home_path" ] || return 1 + [ ! -e "$work_home_path" ] && [ ! -L "$work_home_path" ] || return 1 + return 0 + fi + if [ "$rc" -eq 4 ]; then + [ ! -e "$work_home_path" ] && [ ! -L "$work_home_path" ] || return 1 + return 0 + fi + [ "$rc" -eq 0 ] || return 1 + [ "$home" = "$work_home_path" ] || return 1 state="$home/state" ;; *) return 1 ;; @@ -619,6 +797,24 @@ record_posted() { return "$rc" } +# Delivery keeps the registration. Stamp it delivered and tell the caller the +# public loop is still open. +mark_loop_delivered() { + local id=$1 rc=0 + fm_pf_registry_stamp_delivered "$STATE" "$id" "$(now_rfc3339)" || rc=$? + case "$rc" in + 0) return 0 ;; + 3) return 3 ;; + *) die "could not stamp registration '$id' as delivered after the public reply landed" 1 ;; + esac +} + +print_loop_open_disposition() { + local id=$1 request=$2 + printf "thread %s is still OPEN: hand it on with 'rechain ...' or close it with 'retire %s --reason ...'\n" \ + "${request:-unknown}" "$id" +} + cmd_deliver() { local id=${1:-} text_file= [ -n "$id" ] || { usage; exit 2; } @@ -637,6 +833,7 @@ cmd_deliver() { require_tools local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status + 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 @@ -661,8 +858,9 @@ cmd_deliver() { *) die "obligation '$id' is already $delivery, but its registration is missing or invalid and the legacy X link cannot be verified; reconcile it before any later terminal follow-up" 1 ;; esac fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'already delivered %s state=%s\n' "$id" "$delivery" + [ "$loop_retained" -eq 0 ] || print_loop_open_disposition "$id" "$request" return 0 ;; ready|retry-due|context-blocked|unknown|partial) @@ -743,8 +941,9 @@ EOF 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 fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" + [ "$loop_retained" -eq 0 ] || print_loop_open_disposition "$id" "$request" return 0 fi die "the public reply for '$id' POSTED but its receipt could not be recorded; close it with 'record-posted $id --attempt $attempt --chunks ' before any retry, or the thread will get a second reply" 1 @@ -789,7 +988,7 @@ cmd_record_posted() { || die "public-followup registration for '$id' is missing or invalid; reconcile it before recording a receipt so any legacy X link can be cleared" 1 require_tools - local payload request platform + local payload request platform 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 request=$(pf_field "$payload" '.public_followup.request.request_id') @@ -800,8 +999,9 @@ cmd_record_posted() { 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 fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" + [ "$loop_retained" -eq 0 ] || print_loop_open_disposition "$id" "$request" } # --- subcommand: guard-work ------------------------------------------------- @@ -847,22 +1047,229 @@ EOF [ "$blocked" -eq 0 ] || exit 3 } +# --- subcommand: rechain ---------------------------------------------------- + +rechain_default_deliverable_key() { + case "$1" in + pr-merged) printf 'pr_url\n' ;; + report-ready) printf 'report_path\n' ;; + *) return 1 ;; + esac +} + +cmd_rechain() { + local new_id=${1:-} from='' work_home='' work_id='' expected='' + local -a deliverable_keys=() + [ -n "$new_id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --from) shift; from=${1:-} ;; + --work-home) shift; work_home=${1:-} ;; + --work-id) shift; work_id=${1:-} ;; + --expected) shift; expected=${1:-} ;; + --deliverable-key) shift; deliverable_keys+=("${1:-}") ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot own a public commitment" 1 + require_tools + fm_pf_slug_valid "$new_id" || die "unsafe obligation id: $new_id" + fm_pf_slug_valid "$from" || die "unsafe source obligation id: $from" + fm_pf_slug_valid "$work_id" || die "unsafe work id: $work_id" + fm_pf_home_id_valid "$work_home" \ + || die "work home must be 'main' or 'secondmate:', got '$work_home'" + case "$expected" in + pr-merged|report-ready|local-main) ;; + *) die "--expected must be pr-merged, report-ready, or local-main, got '$expected'" ;; + esac + [ "$new_id" != "$from" ] || die "the new obligation id must differ from --from" 2 + + pf_registry_lock_acquire "$from" \ + || die "could not lock source registration '$from' for rechain" 1 + local src_file loop_state expires window ctx rechain_to source_record first_claim=0 existing + src_file="$(fm_pf_registry_dir "$STATE")/$from" + [ -f "$src_file" ] && [ ! -L "$src_file" ] \ + || die "no registration for '$from' in this home" 1 + loop_state=$(fm_pf_registry_loop_state "$STATE" "$from") + [ "$loop_state" = delivered ] \ + || die "source '$from' is not state=delivered (got '$loop_state'); nothing to hand on until that final lands" 1 + fm_pf_registry_rechainable "$STATE" "$from" \ + || die "source '$from' is un-rechainable: a pre-change registration has no request_context_b64. Close it with retire --reason or reconstruct the request context by hand." 1 + + expires=$(fm_pf_registry_get "$STATE" "$from" followup_expires_at) + [ -n "$expires" ] || die "source '$from' has no followup_expires_at; the thread window cannot be checked" 1 + window=$(fm_pf_followup_window_class "$expires") + case "$window" in + ok|closing) ;; + expired) + die "followup_expires_at $expires is in the past: the thread can no longer be reached, so this loop cannot be closed publicly. This is a captain decision." 1 + ;; + *) + die "followup_expires_at $expires could not be parsed: the thread window cannot be checked, so this loop cannot be rechained" 1 + ;; + esac + + if [ "${#deliverable_keys[@]}" -eq 0 ]; then + local default_key + default_key=$(rechain_default_deliverable_key "$expected") \ + || die "--expected $expected needs --deliverable-key (no default key)" + deliverable_keys+=("$default_key") + fi + local key + for key in "${deliverable_keys[@]}"; do + case "$key" in + ''|*[!a-z0-9_]*) die "deliverable key must be lowercase [a-z0-9_], got '$key'" ;; + esac + done + + # Claim the delivered baton before publishing its destination. The claim is + # retained if any later retirement step fails, so a retry may resume the same + # destination but can never fork this thread into a second obligation. + rechain_to=$(fm_pf_registry_get "$STATE" "$from" rechain_to) + if [ -n "$rechain_to" ] && [ "$rechain_to" != "$new_id" ]; then + die "source '$from' is already claimed by rechain destination '$rechain_to'; resume that destination" 1 + fi + if [ -z "$rechain_to" ]; then + existing=$(obligation_json "$new_id") \ + || die "could not check whether rechain destination '$new_id' is unused" 1 + [ -z "$existing" ] \ + || die "'$new_id' already exists and was not created by this rechain; choose another id" 1 + [ ! -e "$(fm_pf_registry_dir "$STATE")/$new_id" ] \ + && [ ! -L "$(fm_pf_registry_dir "$STATE")/$new_id" ] \ + && [ ! -e "$(fm_pf_retired_dir "$STATE")/$new_id" ] \ + && [ ! -L "$(fm_pf_retired_dir "$STATE")/$new_id" ] \ + || die "'$new_id' already has local public-loop state; choose another id" 1 + source_record=$(grep -v -E '^rechain_to=' "$src_file" 2>/dev/null) \ + || die "could not read source registration '$from' while claiming it" 1 + printf '%s\nrechain_to=%s\n' "$source_record" "$new_id" \ + | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$from" 600 \ + || die "could not claim source registration '$from' for '$new_id'" 1 + first_claim=1 + fi + + local ctx_file expected_file relation_file keys_json project src_payload + ctx=$(fm_pf_registry_get "$STATE" "$from" request_context_b64) + ctx_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-rechain-ctx.XXXXXX") \ + || die "could not stage the retained request context" 1 + expected_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-rechain-exp.XXXXXX") \ + || die "could not stage the expected-final document" 1 + relation_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-rechain-rel.XXXXXX") \ + || die "could not stage the relation document" 1 + PF_TEMP_FILES+=("$ctx_file" "$expected_file" "$relation_file") + printf '%s' "$ctx" | fm_pf_b64_decode > "$ctx_file" \ + || die "could not decode request_context_b64 for '$from'" 1 + jq -e 'type == "object" and (.request_id | type == "string")' "$ctx_file" >/dev/null 2>&1 \ + || die "decoded request context for '$from' is not usable" 1 + + keys_json=$(printf '%s\n' "${deliverable_keys[@]}" | jq -R . | jq -s -c .) + project= + if src_payload=$(obligation_json "$from") && [ -n "$src_payload" ]; then + project=$(pf_field "$src_payload" '.public_followup.expected_final.project') + fi + if [ -n "$project" ]; then + jq -n --arg t "$expected" --arg p "$project" --argjson keys "$keys_json" \ + '{type:$t, project:$p, required_deliverables:$keys, completion_policy:"all-required"}' \ + > "$expected_file" + else + jq -n --arg t "$expected" --argjson keys "$keys_json" \ + '{type:$t, required_deliverables:$keys, completion_policy:"all-required"}' \ + > "$expected_file" + fi + jq -n --arg h "$work_home" --arg w "$work_id" \ + '{relation_id:"rel-1", work_ref:{home_id:$h, task_id:$w}, + role:"fulfills", required:true, generation:1}' > "$relation_file" + + local relation_count new_registry + if [ "$first_claim" -eq 1 ]; then + existing= + else + existing=$(obligation_json "$new_id") \ + || die "could not read the backlog through tasks-axi" 1 + fi + if [ -n "$existing" ]; then + printf '%s' "$existing" | jq -e \ + --slurpfile request "$ctx_file" --slurpfile expected "$expected_file" \ + --arg expires "$expires" \ + '.public_followup as $pf + | $pf.request == $request[0] + and $pf.purpose == "promised-final" + and $pf.expected_final == $expected[0] + and $pf.obligation_expires_at == $expires' >/dev/null 2>&1 \ + || die "'$new_id' already exists with different public-followup data; choose another id" 1 + else + tx public-followup add "$new_id" --request-context-file "$ctx_file" \ + --purpose promised-final --expected-final-file "$expected_file" \ + --expires-at "$expires" >/dev/null \ + || die "tasks-axi refused to add '$new_id' on the retained thread binding" 1 + existing=$(obligation_json "$new_id") \ + || die "added '$new_id' but could not read it back through tasks-axi; retry this same rechain command" 1 + fi + + relation_count=$(printf '%s' "$existing" \ + | jq -r '(.public_followup.work_relations // []) | length' 2>/dev/null) \ + || die "could not inspect work bindings for '$new_id'" 1 + if [ "$relation_count" -eq 0 ]; then + tx public-followup bind-work "$new_id" --relation-file "$relation_file" >/dev/null \ + || die "tasks-axi refused to bind '$new_id' to $work_home/$work_id; retry this same rechain command" 1 + else + printf '%s' "$existing" | jq -e --arg h "$work_home" --arg w "$work_id" \ + '(.public_followup.work_relations // []) as $relations + | ($relations | length) == 1 + and $relations[0].relation_id == "rel-1" + and $relations[0].work_ref.home_id == $h + and $relations[0].work_ref.task_id == $w + and $relations[0].role == "fulfills" + and $relations[0].required == true + and $relations[0].generation == 1' >/dev/null 2>&1 \ + || die "'$new_id' already has a different work binding; choose another id" 1 + fi + + new_registry="$(fm_pf_registry_dir "$STATE")/$new_id" + if [ -f "$new_registry" ] && [ ! -L "$new_registry" ]; then + [ "$(fm_pf_registry_get "$STATE" "$new_id" relation_id)" = rel-1 ] \ + && [ "$(fm_pf_registry_get "$STATE" "$new_id" work_home)" = "$work_home" ] \ + && [ "$(fm_pf_registry_get "$STATE" "$new_id" work_id)" = "$work_id" ] \ + && [ "$(fm_pf_registry_get "$STATE" "$new_id" generation)" = 1 ] \ + || die "registration '$new_id' already names different work; choose another id" 1 + else + cmd_register "$new_id" --relation rel-1 --work-home "$work_home" \ + --work-id "$work_id" --generation 1 >/dev/null \ + || die "could not register '$new_id'; retry this same rechain command" 1 + fi + + cmd_retire "$from" --reason "handed on to $new_id" \ + || die "registered '$new_id' but could not retire '$from'; both loops are open until '$from' is retired" 1 + + cmd_brief "$new_id" +} + # --- subcommand: retire ----------------------------------------------------- cmd_retire() { - local id=${1:-} force=0 payload delivery task_state + local id=${1:-} force=0 reason='' payload delivery task_state registry_file retired_dir retired_at + local retirement_rc=0 [ -n "$id" ] || { usage; exit 2; } shift while [ "$#" -gt 0 ]; do case "$1" in --force) force=1 ;; + --reason) shift; reason=${1:-} ;; *) die "unknown argument '$1'" ;; esac shift || true done fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" fm_pf_relay_active "$FM_HOME" || exit 0 + [ -n "$reason" ] || die "retire requires --reason \"\"" 2 + reason=$(printf '%s' "$reason" | fm_pf_clean_outcome_text) + [ -n "$reason" ] || die "retire requires --reason \"\"" 2 require_tools + pf_registry_lock_acquire "$id" \ + || die "could not lock registration '$id' for retirement" 1 payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 if [ -n "$payload" ]; then @@ -879,8 +1286,24 @@ cmd_retire() { if ! clear_public_followup_link "$id"; then die "could not clear the legacy X link for '$id'; its registration was retained for reconciliation" 1 fi - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true - printf 'retired %s\n' "$id" + retired_dir=$(fm_pf_retired_dir "$STATE") + retired_at=$(now_rfc3339) + registry_file="$(fm_pf_registry_dir "$STATE")/$id" + printf 'reason=%s\nretired_at=%s\n' "$reason" "$retired_at" \ + | fmx_private_artifact_publish_stdin "$retired_dir" "$id" 600 \ + || retirement_rc=1 + if [ "$retirement_rc" -eq 0 ]; then + if ! rm -f -- "$registry_file" 2>/dev/null \ + || [ -e "$registry_file" ] || [ -L "$registry_file" ]; then + retirement_rc=2 + fi + fi + pf_registry_lock_release "$id" + case "$retirement_rc" in + 1) die "could not record the retirement reason for '$id'; the public loop remains open" 1 ;; + 2) die "could not remove registration for '$id'; the public loop remains open" 1 ;; + esac + printf 'retired %s reason=%s\n' "$id" "$reason" } # --- dispatch --------------------------------------------------------------- @@ -901,6 +1324,7 @@ case "$CMD" in deliver) cmd_deliver "$@" ;; record-posted) cmd_record_posted "$@" ;; guard-work) cmd_guard_work "$@" ;; + rechain) cmd_rechain "$@" ;; retire) cmd_retire "$@" ;; *) usage; exit 2 ;; esac diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index ba9d5ccef3..a0383e4681 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -846,11 +846,12 @@ if fm_pf_relay_active "$FM_HOME" \ && { fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE"; }; then PUBLIC_FOLLOWUP=$("$SCRIPT_DIR/fm-public-followup.sh" pending 2>/dev/null) || PUBLIC_FOLLOWUP= if [ -n "$PUBLIC_FOLLOWUP" ]; then - subsection "Public commitments awaiting delivery" + subsection "Public commitments" printf '%s\n' "$PUBLIC_FOLLOWUP" - printf '\nEach line is a public reply this home still owes. Reconcile terminal results with\n' - printf '%s/bin/fm-public-followup.sh consume, then deliver a ready one with\n' "$FM_ROOT" - printf '%s/bin/fm-public-followup.sh deliver . Load fmx-respond for the procedure.\n' "$FM_ROOT" + printf '\nEach line is a public loop this home still holds: a reply still owed, or an open loop with nothing owed.\n' + printf 'Reconcile terminal results with %s/bin/fm-public-followup.sh consume, then deliver a ready one with\n' "$FM_ROOT" + printf '%s/bin/fm-public-followup.sh deliver . Hand a delivered loop on with rechain, or close it with\n' "$FM_ROOT" + printf '%s/bin/fm-public-followup.sh retire --reason "...". Load fmx-respond for the procedure.\n' "$FM_ROOT" fi fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c39c3f7c6a..c84669d363 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2351,6 +2351,22 @@ if [ "$FORCE" != "--force" ] \ fi fi +# Non-blocking: a delivered public loop is not a teardown refusal (guard-work +# already passed), but tearing down a ship whose PR merged while a loop is still +# open with nothing owed is the moment the drop is detectable. +if [ "$KIND" = ship ] && [ -n "$PR_URL" ] \ + && [ -n "$PUBLIC_FOLLOWUP_STATE" ] \ + && [ "${PUBLIC_FOLLOWUP_RELAY_ACTIVE:-0}" = 1 ] \ + && fm_pf_has_delivered_open_loops "$PUBLIC_FOLLOWUP_STATE"; then + echo "warning: an open public loop with nothing owed is still recorded in the consent-holding home while cleaning up ship task $ID. Hand it on with bin/fm-public-followup.sh rechain or close it with retire --reason." >&2 +fi + +# Non-blocking: the legacy Relay link is not guarded as a refusal. +X_REQUEST=$(grep '^x_request=' "$META" 2>/dev/null | tail -1 | cut -d= -f2- || true) +if [ -n "$X_REQUEST" ]; then + echo "warning: task $ID still carries an unreconciled Relay request link ($X_REQUEST) on its task record." >&2 +fi + if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-}." >&2 diff --git a/docs/architecture.md b/docs/architecture.md index 04e1d51eb2..a065c9bff0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -292,7 +292,7 @@ The mechanism boundary is deliberately narrow. `tasks-axi` owns the obligation state machine and is the only thing that validates a terminal result's source home, work id, generation, schema, outcome, and deliverables. `state/x-context/` remains the only owner of the private full request context. `bin/fm-x-reply.sh` remains the only thing that posts. -`bin/fm-public-followup.sh` composes those three and adds nothing of its own beyond the activation gate, a private terminal-event inbox, and the idempotent delivery sequence. +`bin/fm-public-followup.sh` composes those three and adds the activation gate, a private terminal-event inbox, the idempotent delivery sequence, and retained-loop disposition: delivery stamps the registration delivered, `rechain` hands its thread binding to one follow-on obligation, and `retire` is the only close. Work routed to another home reports a *typed* terminal result through `bin/fm-public-followup-emit.sh`; firstmate never recovers the source home, work id, outcome, or deliverables by parsing a free-form `done:` sentence, and the child never learns the thread. Because a terminal event's id is derived from its identity tuple rather than generated, duplicate reports and restart replay converge without coordination. Reconciliation rides the existing relay poll and the session-start digest instead of a new watcher, daemon, or timer, and both are gated on the same `.env` activation contract so a home that never opted into the relay executes none of it. diff --git a/docs/configuration.md b/docs/configuration.md index d9444dd5a1..f6a3d3502e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -485,11 +485,12 @@ These paths need `jq` to build the JSON payload, but they run before token and n ### Promised public replies (state/public-followup) A relay request that spawns real work can leave firstmate owing a specific public reply in a specific thread. -That promise is a typed `kind=public-followup` obligation owned entirely by `tasks-axi public-followup`, with the full private request context staying in `state/x-context/`; firstmate keeps no parallel copy of either. -`bin/fm-public-followup.sh` is firstmate's side: it registers a commitment, reconciles typed terminal work results into it, and posts the final reply through `bin/fm-x-reply.sh --followup`. +That promise is a typed `kind=public-followup` obligation whose state machine is owned entirely by `tasks-axi public-followup`, while the full private conversation context stays only in `state/x-context/`. +Firstmate's bounded registration retains the obligation's public-safe request binding so a delivered loop can be rechained without the original inbox. +`bin/fm-public-followup.sh` is firstmate's side: it registers a commitment, reconciles typed terminal work results into it, posts the final reply through `bin/fm-x-reply.sh --followup`, and explicitly rechains or retires the retained loop. 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 public-safe binding of each live commitment, `events/` for typed terminal results awaiting reconciliation, `consumed/` for the accepted-event ledger, `rejected/` for refusals kept with a one-line reason, and `surfaced` for the poll's last-surfaced signature. +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. 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. @@ -499,10 +500,10 @@ A home without that token runs one file test and stops: no `tasks-axi` call, no Ordinary startup, polling, cleanup, and silent read-side subcommands also produce no output; commands that require an active relay report that configuration error after the same gate. A relay-enabled home with no registered commitment stops at an O(1) directory presence check, so the empty state costs no CLI call and adds no periodic scan. Unreconciled terminal results ride the existing 30-second relay poll rather than a new process or timer: `bin/fm-x-poll.sh` compares the pending-event signature against `surfaced` and wakes firstmate once per new result set. -The session-start digest separately prints an "Public commitments awaiting delivery" subsection from disk when, and only when, this home is relay-active and still owes a reply, so compaction and restart are non-events. +The session-start digest separately prints a "Public commitments" subsection from disk when, and only when, this home is relay-active and still holds an open public loop (a reply still owed, or a delivered loop with nothing owed), so compaction and restart are non-events. `bin/fm-teardown.sh` refuses to clean up a task while this home still owes a public reply for exactly that work, unless `--force` carries explicit discard approval. `FM_PF_RETRY_BACKOFF_SECS` (default 900) sets the next-attempt time recorded with a retryable delivery error. -See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind the restart end-to-end and the relay-disabled zero-overhead guarantee. +See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind restart recovery, retained-loop disposition, and the relay-disabled zero-overhead guarantee. ## Process-to-event sources (state/procevent) diff --git a/docs/scripts.md b/docs/scripts.md index 9f219592d7..8c3ad45f0a 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -119,6 +119,6 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-x-dismiss.sh` | Dismiss a skipped Relay mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating Relay mention in task meta | | `fm-x-followup.sh` | Detect, post, and cap completion follow-ups for a Relay-linked task | -| `fm-public-followup-lib.sh` | Shared relay-activation gate, O(1) presence checks, and private transport paths for promised public replies | -| `fm-public-followup.sh` | Reconcile typed terminal work results into a public commitment and deliver its final reply once | +| `fm-public-followup-lib.sh` | Shared Relay gate, open-loop registry state, expiry classification, locking, and private transport paths | +| `fm-public-followup.sh` | Reconcile and deliver typed public commitments, then rechain or explicitly retire their retained loops | | `fm-public-followup-emit.sh` | Report one typed terminal work result into the home that owes the public reply | diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md index 3bad5a605d..373bee3596 100644 --- a/docs/verification/public-followup.md +++ b/docs/verification/public-followup.md @@ -2,17 +2,18 @@ Audience: maintainer verification. -This record supports two active guarantees for promised public replies made through the myfirstmate relay: +This record supports three active guarantees for promised public replies made through the myfirstmate relay: 1. A promised final reply survives compaction and restart, reconciles from disk alone, and lands in the original thread exactly once. 2. A home that never opted into the relay pays nothing for any of it. +3. Delivering a final does not close the public loop: the registration is retained as `state=delivered` until `retire --reason`, session start surfaces an `open-loop` line, and `rechain` can bind follow-on work to the same thread. [`docs/configuration.md`](../configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, [`docs/architecture.md`](../architecture.md#optional-relay) owns the mechanism boundary, and `tasks-axi public-followup --help` owns the typed obligation schema. Task chronology and delivery evidence stay outside this record. ## Environment -Recorded 2026-07-30 on Darwin 25.5.0 (arm64) with GNU bash 5.3.9, tasks-axi 0.2.3, jq 1.8.1, and ShellCheck 0.11.0 (the version `bin/fm-lint.sh` pins). +Recorded 2026-08-21 on Darwin 25.5.0 (arm64) with GNU bash 5.3.9, tasks-axi 0.2.5, jq 1.8.1, and ShellCheck 0.11.0 (the version `bin/fm-lint.sh` pins). The relay is a fakebin `curl` in every case, so no public post is ever made; `tasks-axi` and `jq` are the real tools, because stubbing the obligation state machine would verify nothing. ## Restart end-to-end and regressions @@ -27,9 +28,25 @@ ok - restart end-to-end: typed result reconciles from disk and delivers one repl ok - duplicate terminal results, restart replay, and repeated delivery are all no-ops ok - wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused ok - a relay transport failure is held as retryable with no false completion, and the retry posts once +ok - a dry-run records no public delivery and leaves the commitment retryable ok - a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused +ok - typed terminal cleanup clears the legacy link without posting ok - a delivery interrupted between post and receipt refuses to repost ok - a child home reports typed results but can never become the outward-post owner +ok - typed delivery refuses to post when its cleanup registration is missing +ok - marked secondmate teardown resolves its parent and fails closed when unavailable +ok - local seeding publishes durable parent state before its identity marker +ok - a lost launch-time parent binding is recovered from the durable local record +ok - a durable local parent record does not bypass a genuinely missing parent-side registration +ok - unknown durable parent fields remain forward-compatible +ok - conflicting live and durable parent bindings fail closed +ok - unsafe durable parent records fail closed before cleanup +ok - a NUL-bearing durable parent record fails closed before cleanup +ok - relay-disabled unmarked teardown runs no public-followup work +ok - a marked child proceeds without tasks-axi when its parent relay is disabled +ok - secondmate parent resolution matches the durable registry id literally +ok - traversal-shaped registrations are rejected before path construction or posting +ok - pending keeps registrations when tasks-axi returns malformed JSON ok - the retained private request context keeps the original thread deliverable after inbox cleanup ok - cleanup refuses while a public reply is owed and proceeds once it has landed ok - a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact @@ -38,20 +55,39 @@ ok - a relay-exhausted follow-up binding is escalated rather than retried into t ok - the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once ok - startup surfaces unresolved public commitments only in a relay home that owes one ok - typed public-followup records carry only public-safe summaries and deliverables +ok - dropped-baton regression: delivery retains the loop and pending prints open-loop +ok - CONTROL: the identical teardown REFUSES the moment a commitment is registered +ok - rechain posts the shipped follow-on into the same thread +ok - rechain resumes the same obligation after an interrupted bind +ok - concurrent rechains cannot fork one delivered source +ok - failed rechain retirement keeps the source claimed by one resumable destination +ok - registration replay preserves delivered and retired loop states +ok - redelivery does not report a retired loop as open +ok - retire closes delivered loops after secondmate home removal +ok - retire fails closed for an unbound existing secondmate +ok - retire fails closed when a secondmate ID is reassigned +ok - rechain refuses an unrelated existing destination +ok - pending skips a registration retired during settlement +ok - retire --reason closes the loop and drops the open-loop line +ok - retention creates no false teardown refusal and pending no longer prunes +ok - expiry escalation is pinned by FMX_NOW_OVERRIDE +ok - brief fails explicitly when typed deliverable keys are unavailable +ok - pre-change registrations are open loops and un-rechainable, never a crash +ok - teardown reports an unreconciled legacy Relay link +ok - secondmate promotion matches teardown parent resolution ``` -The first case is the end-to-end proof. +The restart case is the end-to-end proof of guarantee 1. It reproduces the stranded state first (work bound, no reconciled terminal result, delivery refused with "still waiting on its bound work" and zero posts), then has a secondmate-shaped child report a typed `pr-merged` result, deletes the drained inbox payload, reconciles from disk, and asserts exactly one `connector/followup` call carrying the original `request_id`, a validated `posted` receipt, and a Done obligation. -The existing Relay suite is unchanged by this work: +The dropped-baton case is the end-to-end proof of guarantee 3. +It delivers a `report-ready` promised-final, asserts the registration is retained and `pending` prints `open-loop`, then shows that an unbound follow-on ship is not teardown-refused (the one-variable control still refuses the moment a commitment is registered for that work). +`rechain` then binds a fresh `pr-merged` obligation onto the same request/thread, and a second follow-up carries the shipped text. +`retire --reason` records its private receipt before removal and is the only close; replayed registration cannot reopen that retired loop. +The concurrency and interrupted-bind cases verify that one delivered source cannot fork and that retry converges on the same destination obligation. +A pre-change on-disk record (no `state=`, no `request_context_b64`) is an open loop and un-rechainable rather than a crash. -```sh -bash tests/fm-x-mode.test.sh | grep -c '^ok -' -``` - -``` -103 -``` +The existing Relay mention suite (`tests/fm-x-mode.test.sh`) is unchanged by this work. ## Relay-disabled zero overhead @@ -66,10 +102,10 @@ for i in $(seq 1 1000); do fm_pf_relay_active "$HOME_DIR" || true; done ``` ``` -total_ns=69694000 per_call_us=69 +total_ns=22305959 per_call_us=22 ``` -Roughly 0.07 ms per session start, from a single `[ -f "$FM_HOME/.env" ]` test that returns false before anything else runs. +Roughly 0.02 ms per session start, from a single `[ -f "$FM_HOME/.env" ]` test that returns false before anything else runs. ## Compatibility axes reviewed @@ -79,4 +115,5 @@ The only supervision surfaces touched are the session-start digest, which `bin/f Runtime backends (tmux, herdr, zellij, orca, cmux): not applicable after inspection. No command here reads `state/.meta`'s backend fields, resolves an endpoint, or captures a pane. -The one lifecycle integration is `bin/fm-teardown.sh`'s refusal, which runs before any backend command and keys only on the task id, so it behaves identically on every backend. +The lifecycle integrations are backlog-handoff warnings, promotion rechain hints, and `bin/fm-teardown.sh`'s owed-reply refusal plus non-blocking open-loop and legacy `x_request=` warnings. +They inspect home, task, parent-binding, and registration records rather than backend fields or endpoints, so they behave identically on every backend. diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index fe15e239e2..69a054abee 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -20,6 +20,7 @@ PF="$ROOT/bin/fm-public-followup.sh" EMIT="$ROOT/bin/fm-public-followup-emit.sh" POLL="$ROOT/bin/fm-x-poll.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" +PROMOTE="$ROOT/bin/fm-promote.sh" SESSION_START="$ROOT/bin/fm-session-start.sh" TMP_ROOT=$(fm_test_tmproot fm-public-followup) @@ -150,6 +151,34 @@ seed_commitment() { || fail "could not register the public commitment" } +# The pi-rearm shape: a report-ready promised-final bound to a secondmate. +seed_repro_commitment() { # + local home=$1 obligation=$2 request=$3 work_home=$4 work_id=$5 + jq -n --arg r "$request" \ + '{request_id:$r, platform:"discord", + context_binding:{version:"ctx1", value:("ctx1_" + $r)}, + public_safe_summary:"reproduce a Pi recovery notification loop", + received_at:"2026-08-21T01:12:00Z", + followup_expires_at:"2026-08-28T01:12:00Z", + reservation_expires_at:"2026-08-28T01:12:00Z"}' > "$home/request.json" + jq -n '{type:"report-ready", project:"firstmate", + required_deliverables:["report_path"], completion_policy:"all-required"}' \ + > "$home/expected.json" + jq -n --arg h "$work_home" --arg w "$work_id" \ + '{relation_id:"rel-code", work_ref:{home_id:$h, task_id:$w}, + role:"fulfills", required:true, generation:1}' > "$home/relation.json" + tasks_in "$home" public-followup add "$obligation" --request-context-file "$home/request.json" \ + --purpose promised-final --expected-final-file "$home/expected.json" \ + --expires-at 2026-10-01T00:00:00Z >/dev/null || fail "add failed" + tasks_in "$home" public-followup bind-work "$obligation" --relation-file "$home/relation.json" >/dev/null \ + || fail "bind-work failed" + FM_HOME="$home" bash -c \ + ". '$ROOT/bin/fm-x-lib.sh'; fmx_context_registry_set '$home/state' '$request' discord 2000" \ + || fail "context retain failed" + run_pf "$home" register "$obligation" --relation rel-code --work-home "$work_home" \ + --work-id "$work_id" --generation 1 >/dev/null || fail "register failed" +} + emit_terminal() { # [pr-url] [outcome] local owning=$2 obligation=$3 work_home=$4 work_id=$5 local pr=${6:-https://github.com/example/repo/pull/7} outcome=${7:-pr-merged} @@ -240,9 +269,9 @@ test_restart_e2e_delivers_exactly_once() { home=$(make_home restart-e2e) child=$(make_home restart-child relay-off) log="$home/curl.log"; : > "$log" - seed_commitment "$home" pf-restart req-restart discord secondmate:fmdev work-code-q1 printf '%s\n' fmdev > "$child/.fm-secondmate-home" fm_write_meta "$home/state/fmdev.meta" "kind=secondmate" "home=$child" + seed_commitment "$home" pf-restart req-restart discord secondmate:fmdev work-code-q1 fm_write_meta "$child/state/work-code-q1.meta" \ "x_request=req-restart" "x_request_ts=1700000000" "x_followups=1" @@ -533,9 +562,9 @@ test_outward_delivery_stays_with_the_owning_home() { owner=$(make_home owner) child=$(make_home child relay-off) log="$owner/curl.log"; : > "$log" - seed_commitment "$owner" pf-own req-own discord secondmate:child work-child printf '%s\n' child > "$child/.fm-secondmate-home" fm_write_meta "$owner/state/child.meta" "kind=secondmate" "home=$child" + seed_commitment "$owner" pf-own req-own discord secondmate:child work-child fm_write_meta "$child/state/work-child.meta" \ "x_request=req-own" "x_request_ts=1700000000" "x_followups=1" @@ -1165,6 +1194,14 @@ SH [ -z "$out" ] || fail "'$cmd' must print nothing in a relay-disabled home, got: $out" done + # New fields and the open-loop gate must not create work in a disabled home. + # shellcheck disable=SC1091 + . "$ROOT/bin/fm-public-followup-lib.sh" + fm_pf_has_open_loops "$home/state" \ + && fail "a relay-disabled home must not grow an open-loop registry" + fm_pf_has_delivered_open_loops "$home/state" \ + && fail "a relay-disabled home must not grow a delivered open-loop registry" + rc=0 PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" active || rc=$? @@ -1290,7 +1327,7 @@ test_session_start_surfaces_only_when_owed() { out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ FM_STATE_OVERRIDE="$on/state" FM_DATA_OVERRIDE="$on/data" \ FM_CONFIG_OVERRIDE="$on/config" "$SESSION_START" 2>&1) - assert_contains "$out" "Public commitments awaiting delivery" \ + assert_contains "$out" "Public commitments" \ "an unresolved commitment must be surfaced at startup" assert_contains "$out" "unresolved pf-start state=pending-work platform=discord" \ "the startup summary must be typed and actionable" @@ -1323,6 +1360,847 @@ test_typed_records_exclude_raw_public_material() { pass "typed public-followup records carry only public-safe summaries and deliverables" } +# --- 10. delivery does not close the public loop -------------------------------- + +test_dropped_baton_now_surfaces_open_loop() { + local parent child log + parent=$(make_home baton-parent) + child="$TMP_ROOT/baton-child" + FM_SECONDMATE_CHARTER='Baton repro charter.' FM_HOME="$parent" \ + "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null || fail "seed failed" + child=$(cd "$child" && pwd -P) + make_fake_curl "$child" >/dev/null + fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi + log="$TMP_ROOT/curl.log"; : > "$log" + + seed_repro_commitment "$parent" public-final-pi-rearm-repro req-pirearm \ + secondmate:mate pi-rearm-loop-repro-s1 + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + + "$EMIT" --home "$parent" --obligation public-final-pi-rearm-repro --relation rel-code \ + --source-home secondmate:mate --work-id pi-rearm-loop-repro-s1 --generation 1 \ + --outcome report-ready --deliverable report_path=data/pi-rearm-loop-repro-s1/report.md \ + --outcome-text 'Reproduced the loop. A bounded fix is scoped and waiting on you, captain.' >/dev/null \ + || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$parent" consume | grep -q '^ready ' || fail "consume not ready" + FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-pi-rearm-repro >/dev/null || fail "deliver failed" + [ "$(followup_posts "$log")" = 1 ] || fail "expected exactly one closing post" + assert_present "$parent/state/public-followup/registry/public-final-pi-rearm-repro" \ + "delivery must retain the registration" + + fm_write_meta "$child/state/pi-rearm-loop-fix-r1.meta" \ + "window=firstmate:fm-pi-rearm-loop-fix-r1" "endpoint_task_id=pi-rearm-loop-fix-r1" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$parent/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$parent" \ + FM_STATE_OVERRIDE="$parent/state" "$PF" guard-work secondmate:mate pi-rearm-loop-fix-r1 \ + || fail "guard-work unexpectedly blocked the unregistered follow-on" + + run_pf "$parent" pending > "$TMP_ROOT/pending.out" + grep -q '^open-loop public-final-pi-rearm-repro ' "$TMP_ROOT/pending.out" \ + || fail "pending must print the open-loop line after delivery" + grep -q 'request=req-pirearm' "$TMP_ROOT/pending.out" \ + || fail "the open-loop line must name the original request" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" FAKE_CURL_LOG="$log" \ + "$TEARDOWN" pi-rearm-loop-fix-r1 > "$TMP_ROOT/td.out" 2>&1 || true + case "$(cat "$TMP_ROOT/td.out")" in + *"still owes a public reply"*) fail "teardown unexpectedly guarded the unregistered follow-on" ;; + esac + [ "$(followup_posts "$log")" = 1 ] || fail "unexpected extra post" + pass "dropped-baton regression: delivery retains the loop and pending prints open-loop" +} + +test_control_registered_followon_is_guarded() { + local parent child + parent=$(make_home baton-control-parent) + child="$TMP_ROOT/baton-control-child" + FM_SECONDMATE_CHARTER='Baton control charter.' FM_HOME="$parent" \ + "$ROOT/bin/fm-home-seed.sh" mate "$child" --no-projects >/dev/null || fail "seed failed" + child=$(cd "$child" && pwd -P) + make_fake_curl "$child" >/dev/null + fm_fake_exit0 "$child/fakebin" tmux treehouse no-mistakes gh gh-axi + seed_repro_commitment "$parent" public-final-pi-rearm-ship req-pirearm2 \ + secondmate:mate pi-rearm-loop-fix-r1 + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/pi-rearm-loop-fix-r1.meta" \ + "window=firstmate:fm-pi-rearm-loop-fix-r1" "endpoint_task_id=pi-rearm-loop-fix-r1" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + expect_failure "registered follow-on must be guarded" "$TEARDOWN" pi-rearm-loop-fix-r1 + assert_contains "$EXPECT_OUT" "still owes a public reply" "the guard fires only on presence" + pass "CONTROL: the identical teardown REFUSES the moment a commitment is registered" +} + +test_rechain_delivers_second_post_on_same_thread() { + local parent log out posts command command_log + parent=$(make_home rechain-parent) + log="$parent/curl.log"; : > "$log" + seed_repro_commitment "$parent" public-final-a req-rechain main scout-a + "$EMIT" --home "$parent" --obligation public-final-a --relation rel-code \ + --source-home main --work-id scout-a --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-a/report.md \ + --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$parent" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-a >/dev/null || fail "deliver failed" + [ "$(followup_posts "$log")" = 1 ] || fail "expected the investigation post" + + out=$(FAKE_CURL_LOG="$log" run_pf "$parent" rechain public-final-b --from public-final-a \ + --work-home main --work-id ship-b --expected pr-merged) \ + || fail "rechain failed: $out" + assert_contains "$out" "retired public-final-a reason=handed on to public-final-b" \ + "rechain must retire the source loop" + assert_contains "$out" "--deliverable pr_url=" \ + "rechain brief must name the actual required deliverable key" + command_log="$parent/brief-command.args" + cat > "$parent/fakebin/record-emit" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$RECORD_ARGS" +SH + chmod +x "$parent/fakebin/record-emit" + command=$(printf '%s\n' "$out" | awk ' + index($0, "/bin/fm-public-followup-emit.sh") { capture=1 } + capture { if ($0 == "") exit; print } + ') + assert_contains "$command" "--outcome-text" \ + "the exact rechain command must remain continuous through outcome text" + command=${command/"$ROOT/bin/fm-public-followup-emit.sh"/"$parent/fakebin/record-emit"} + command=${command///https://github.com/example/repo/pull/99} + RECORD_ARGS="$command_log" bash -c "$command" \ + || fail "the exact rechain command must execute after filling its deliverable value" + assert_grep '--deliverable' "$command_log" \ + "the executable rechain command must pass its deliverable option" + assert_grep '--outcome-text' "$command_log" \ + "the executable rechain command must pass its outcome text option" + assert_absent "$parent/state/public-followup/registry/public-final-a" \ + "the source registration must be gone after rechain" + assert_present "$parent/state/public-followup/registry/public-final-b" \ + "the follow-on registration must exist" + + "$EMIT" --home "$parent" --obligation public-final-b --relation rel-1 \ + --source-home main --work-id ship-b --generation 1 \ + --outcome pr-merged --deliverable pr_url=https://github.com/example/repo/pull/99 \ + --outcome-text 'Shipped: the Pi recovery loop is fixed.' >/dev/null || fail "follow-on emit failed" + FAKE_CURL_LOG="$log" run_pf "$parent" consume | grep -q '^ready public-final-b ' \ + || fail "follow-on consume not ready" + FAKE_CURL_LOG="$log" run_pf "$parent" deliver public-final-b >/dev/null || fail "follow-on deliver failed" + posts=$(followup_posts "$log") + [ "$posts" = 2 ] || fail "expected exactly two posts in the same thread, got $posts" + assert_grep 'Shipped: the Pi recovery loop is fixed.' "$log" \ + "the second post must carry the shipped text" + assert_grep '"request_id":"req-rechain"' "$log" \ + "both posts must target the original request" + pass "rechain posts the shipped follow-on into the same thread" +} + +test_rechain_resumes_after_partial_add() { + local home log real_tasks marker out count + home=$(make_home rechain-resume) + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" public-final-resume-a req-resume main scout-resume + "$EMIT" --home "$home" --obligation public-final-resume-a --relation rel-code \ + --source-home main --work-id scout-resume --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-resume/report.md \ + --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-resume-a >/dev/null \ + || fail "deliver failed" + + real_tasks=$(command -v tasks-axi) + marker="$home/rechain-bind-failed" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "$1" = public-followup ] && [ "$2" = bind-work ] \ + && [ "$3" = public-final-resume-b ] && [ ! -e "$RECHAIN_FAIL_MARKER" ]; then + : > "$RECHAIN_FAIL_MARKER" + exit 73 +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + + REAL_TASKS_AXI="$real_tasks" RECHAIN_FAIL_MARKER="$marker" \ + expect_failure "rechain must expose a resumable partial add" \ + run_pf "$home" rechain public-final-resume-b --from public-final-resume-a \ + --work-home main --work-id ship-resume --expected pr-merged + assert_contains "$EXPECT_OUT" "retry this same rechain command" \ + "a partial add must direct the caller to the resumable path" + count=$(tasks_in "$home" public-followup list --json \ + | jq '[.public_followups[] | select(.id == "public-final-resume-b")] | length') + [ "$count" = 1 ] || fail "the interrupted rechain must leave exactly one recoverable obligation" + assert_present "$home/state/public-followup/registry/public-final-resume-a" \ + "a partial rechain must retain the source loop" + assert_absent "$home/state/public-followup/registry/public-final-resume-b" \ + "a failed bind must not publish a registration" + + out=$(REAL_TASKS_AXI="$real_tasks" RECHAIN_FAIL_MARKER="$marker" \ + run_pf "$home" rechain public-final-resume-b --from public-final-resume-a \ + --work-home main --work-id ship-resume --expected pr-merged) \ + || fail "retrying the same rechain command must resume: $out" + assert_contains "$out" "retired public-final-resume-a" \ + "the resumed rechain must retire its source" + count=$(tasks_in "$home" public-followup list --json \ + | jq '[.public_followups[] | select(.id == "public-final-resume-b")] | length') + [ "$count" = 1 ] || fail "the resumed rechain must not duplicate the obligation" + assert_present "$home/state/public-followup/registry/public-final-resume-b" \ + "the resumed rechain must publish the destination registration" + assert_absent "$home/state/public-followup/registry/public-final-resume-a" \ + "the resumed rechain must close the source registration" + pass "rechain resumes the same obligation after an interrupted bind" +} + +test_rechain_claims_delivered_source_once() { + local home log pid_b pid_c rc_b=0 rc_c=0 registry_count + home=$(make_home rechain-claim) + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" public-final-claim-a req-claim main scout-claim + "$EMIT" --home "$home" --obligation public-final-claim-a --relation rel-code \ + --source-home main --work-id scout-claim --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-claim/report.md \ + --outcome-text 'Reproduced. One follow-on may claim this thread.' >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-claim-a >/dev/null || fail "deliver failed" + + FMX_NOW_OVERRIDE=1787539200 run_pf "$home" rechain public-final-claim-b \ + --from public-final-claim-a --work-home main --work-id ship-claim-b \ + --expected pr-merged > "$home/rechain-b.out" 2>&1 & + pid_b=$! + FMX_NOW_OVERRIDE=1787539200 run_pf "$home" rechain public-final-claim-c \ + --from public-final-claim-a --work-home main --work-id ship-claim-c \ + --expected pr-merged > "$home/rechain-c.out" 2>&1 & + pid_c=$! + wait "$pid_b" || rc_b=$? + wait "$pid_c" || rc_c=$? + + if [ "$rc_b" -eq 0 ]; then + [ "$rc_c" -ne 0 ] || fail "two concurrent rechains must not both claim one source" + else + [ "$rc_c" -eq 0 ] || fail "exactly one concurrent rechain must succeed" + fi + registry_count=$(find "$home/state/public-followup/registry" -type f \ + \( -name 'public-final-claim-b' -o -name 'public-final-claim-c' \) | wc -l | tr -d ' ') + [ "$registry_count" = 1 ] || fail "one source must produce exactly one registered destination" + assert_absent "$home/state/public-followup/registry/public-final-claim-a" \ + "the successfully claimed source must be retired" + pass "concurrent rechains cannot fork one delivered source" +} + +test_failed_rechain_retirement_keeps_source_claimed() { + local home log registry_file out + home=$(make_home rechain-retire-failure) + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" public-final-retire-a req-retire-failure main scout-retire + "$EMIT" --home "$home" --obligation public-final-retire-a --relation rel-code \ + --source-home main --work-id scout-retire --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-retire/report.md \ + --outcome-text 'Reproduced. A fix is waiting.' >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-retire-a >/dev/null \ + || fail "deliver failed" + + registry_file="$home/state/public-followup/registry/public-final-retire-a" + cat > "$home/fakebin/rm" < "$log" + seed_commitment "$home" pf-register-replay req-register-replay discord main work-register-replay + jq -n '{relation_id:"rel-alternate", work_ref:{home_id:"main", task_id:"work-alternate"}, + role:"fulfills", required:false, generation:1}' > "$home/alternate-relation.json" + tasks_in "$home" public-followup bind-work pf-register-replay \ + --relation-file "$home/alternate-relation.json" >/dev/null \ + || fail "could not add the alternate valid work binding" + emit_terminal "$home" "$home" pf-register-replay main work-register-replay >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "initial consume failed" + run_pf "$home" register pf-register-replay --relation rel-alternate --work-home main \ + --work-id work-alternate --generation 1 >/dev/null \ + || fail "could not register the alternate valid work binding" + "$EMIT" --home "$home" --obligation pf-register-replay --relation rel-alternate \ + --source-home main --work-id work-alternate --generation 1 --outcome pr-merged \ + --deliverable pr_url=https://github.com/example/repo/pull/8 \ + --outcome-text 'The alternate bound work also reached its terminal outcome.' >/dev/null \ + || fail "alternate emit failed" + run_pf "$home" consume >/dev/null || fail "alternate consume failed" + run_pf "$home" register pf-register-replay --relation rel-code --work-home main \ + --work-id work-register-replay --generation 1 >/dev/null \ + || fail "could not restore the original open registration" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-register-replay >/dev/null || fail "deliver failed" + + run_pf "$home" register pf-register-replay --relation rel-code --work-home main \ + --work-id work-register-replay --generation 1 >/dev/null || fail "registration replay failed" + registry="$home/state/public-followup/registry/pf-register-replay" + grep -q '^state=delivered$' "$registry" \ + || fail "registration replay must not downgrade a delivered loop" + + snapshot="$home/registry-before-replay" + cp "$registry" "$snapshot" + run_pf "$home" register pf-register-replay --relation rel-alternate --work-home main \ + --work-id work-alternate --generation 1 --platform x --request req-alternate >/dev/null \ + || fail "delivered registration replay against another valid binding failed" + cmp -s "$snapshot" "$registry" \ + || fail "delivered registration replay must preserve the complete retained baton" + + run_pf "$home" retire pf-register-replay --reason "finished after replay" >/dev/null \ + || fail "retire after replay failed" + expect_failure "registration replay must not reopen a retired loop" \ + run_pf "$home" register pf-register-replay --relation rel-code --work-home main \ + --work-id work-register-replay --generation 1 + assert_contains "$EXPECT_OUT" "already been retired" \ + "a retirement receipt must make registration fail closed" + assert_absent "$home/state/public-followup/registry/pf-register-replay" \ + "registration replay must not recreate a retired loop" + pass "registration replay preserves delivered and retired loop states" +} + +test_redelivery_does_not_report_retired_loop_open() { + local home log out + home=$(make_home redelivery-retired) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-redelivery-retired req-redelivery-retired discord main work-redelivery-retired + emit_terminal "$home" "$home" pf-redelivery-retired main work-redelivery-retired >/dev/null \ + || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-redelivery-retired >/dev/null \ + || fail "initial delivery failed" + run_pf "$home" retire pf-redelivery-retired --reason "thread finished" >/dev/null \ + || fail "retire failed" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-redelivery-retired) \ + || fail "idempotent redelivery failed" + assert_contains "$out" "already delivered pf-redelivery-retired" \ + "redelivery must remain idempotent" + case "$out" in + *"still OPEN"*) fail "redelivery must not report a retired loop as open: $out" ;; + esac + [ "$(grep -c '^url=.*connector/followup' "$log" || true)" -eq 1 ] \ + || fail "redelivery must not post a second public reply" + pass "redelivery does not report a retired loop as open" +} + +test_retire_after_secondmate_home_removal() { + local home child log out + home=$(make_home retire-removed-secondmate) + child="$home/removed-mate" + mkdir -p "$child/state" + printf 'mate\n' > "$child/.fm-secondmate-home" + fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$child" + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" pf-removed-mate req-removed-mate secondmate:mate scout-removed + "$EMIT" --home "$home" --obligation pf-removed-mate --relation rel-code \ + --source-home secondmate:mate --work-id scout-removed --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-removed/report.md \ + --outcome-text 'The removed child completed its investigation.' >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-removed-mate >/dev/null || fail "deliver failed" + + rm -rf "$child" + rm -f "$home/state/mate.meta" + out=$(run_pf "$home" retire pf-removed-mate --reason "child home was torn down") \ + || fail "retire must accept an already-absent child legacy link: $out" + assert_contains "$out" "retired pf-removed-mate" \ + "retire must close a delivered loop after its secondmate home is removed" + assert_absent "$home/state/public-followup/registry/pf-removed-mate" \ + "retire must remove the registration after child teardown" + assert_present "$home/state/public-followup/retired/pf-removed-mate" \ + "retire must still record its receipt" + pass "retire closes delivered loops after secondmate home removal" +} + +test_retire_refuses_unbound_existing_secondmate() { + local home child log + home=$(make_home retire-unbound-secondmate) + child="$home/unbound-mate" + mkdir -p "$child/state" + child=$(cd "$child" && pwd -P) + printf 'mate\n' > "$child/.fm-secondmate-home" + fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$child" + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" pf-unbound-mate req-unbound-mate secondmate:mate scout-unbound + "$EMIT" --home "$home" --obligation pf-unbound-mate --relation rel-code \ + --source-home secondmate:mate --work-id scout-unbound --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-unbound/report.md \ + --outcome-text 'The unbound child completed its investigation.' >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-unbound-mate >/dev/null || fail "deliver failed" + fm_write_meta "$child/state/scout-unbound.meta" "status=working" "x_request=req-unbound-mate" + assert_grep "work_home_path=$child" \ + "$home/state/public-followup/registry/pf-unbound-mate" \ + "registration must retain the canonical secondmate path" + rm -f "$home/state/mate.meta" + + expect_failure "retire must not assume an unbound existing child link is cleared" \ + run_pf "$home" retire pf-unbound-mate --reason "child binding disappeared" + assert_contains "$EXPECT_OUT" "could not clear the legacy X link" \ + "retire must report an unverifiable secondmate legacy link" + assert_present "$home/state/public-followup/registry/pf-unbound-mate" \ + "an unverifiable child link must retain the registration" + assert_absent "$home/state/public-followup/retired/pf-unbound-mate" \ + "an unverifiable child link must not create a retirement receipt" + assert_grep 'x_request=req-unbound-mate' "$child/state/scout-unbound.meta" \ + "failed retirement must preserve the unresolved legacy link" + pass "retire fails closed for an unbound existing secondmate" +} + +test_retire_refuses_reassigned_secondmate_home() { + local home original replacement log + home=$(make_home retire-reassigned-secondmate) + original="$home/original-mate" + replacement="$home/replacement-mate" + mkdir -p "$original/state" + printf 'mate\n' > "$original/.fm-secondmate-home" + fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$original" + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" pf-reassigned-mate req-reassigned-mate secondmate:mate scout-reassigned + "$EMIT" --home "$home" --obligation pf-reassigned-mate --relation rel-code \ + --source-home secondmate:mate --work-id scout-reassigned --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-reassigned/report.md \ + --outcome-text 'The original child completed its investigation.' >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-reassigned-mate >/dev/null || fail "deliver failed" + + rm -rf "$original" + mkdir -p "$replacement/state" + replacement=$(cd "$replacement" && pwd -P) + printf 'mate\n' > "$replacement/.fm-secondmate-home" + fm_write_meta "$replacement/state/scout-reassigned.meta" \ + "status=working" "x_request=req-unrelated-replacement" + fm_write_meta "$home/state/mate.meta" "kind=secondmate" "home=$replacement" + + expect_failure "retire must not clear a reassigned secondmate home" \ + run_pf "$home" retire pf-reassigned-mate --reason "original child was removed" + assert_contains "$EXPECT_OUT" "could not clear the legacy X link" \ + "retirement must fail when the stable ID resolves to a different home" + assert_present "$home/state/public-followup/registry/pf-reassigned-mate" \ + "a reassigned child must retain the registration" + assert_absent "$home/state/public-followup/retired/pf-reassigned-mate" \ + "a reassigned child must not create a retirement receipt" + assert_grep 'x_request=req-unrelated-replacement' "$replacement/state/scout-reassigned.meta" \ + "failed retirement must preserve the replacement home's Relay link" + pass "retire fails closed when a secondmate ID is reassigned" +} + +test_rechain_refuses_unclaimed_existing_destination() { + local home log out + home=$(make_home rechain-existing-destination) + log="$home/curl.log"; : > "$log" + seed_repro_commitment "$home" public-final-existing-a req-existing main scout-existing + "$EMIT" --home "$home" --obligation public-final-existing-a --relation rel-code \ + --source-home main --work-id scout-existing --generation 1 \ + --outcome report-ready --deliverable report_path=data/scout-existing/report.md \ + --outcome-text 'Investigation complete.' >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver public-final-existing-a >/dev/null \ + || fail "deliver failed" + + jq -n '{type:"pr-merged", project:"firstmate", required_deliverables:["pr_url"], + completion_policy:"all-required"}' > "$home/collision-expected.json" + tasks_in "$home" public-followup add public-final-existing-b \ + --request-context-file "$home/request.json" --purpose promised-final \ + --expected-final-file "$home/collision-expected.json" \ + --expires-at 2026-08-28T01:12:00Z >/dev/null || fail "could not seed destination collision" + + expect_failure "a first rechain must not adopt an unrelated existing obligation" \ + run_pf "$home" rechain public-final-existing-b --from public-final-existing-a \ + --work-home main --work-id ship-existing --expected pr-merged + assert_contains "$EXPECT_OUT" "was not created by this rechain" \ + "the collision refusal must identify the unclaimed destination" + out=$(cat "$home/state/public-followup/registry/public-final-existing-a") + case "$out" in + *rechain_to=*) fail "a destination collision must not claim the source" ;; + esac + assert_absent "$home/state/public-followup/registry/public-final-existing-b" \ + "an unrelated obligation must not become a registered destination" + pass "rechain refuses an unrelated existing destination" +} + +test_pending_skips_concurrent_retirement() { + local home log real_tasks pending_pid locker_pid rc=0 + home=$(make_home pending-retirement-race) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-race req-race discord main work-race + emit_terminal "$home" "$home" pf-race main work-race >/dev/null || fail "race emit failed" + run_pf "$home" consume >/dev/null || fail "race consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-race >/dev/null || fail "race deliver failed" + sed -e 's/^state=delivered$/state=open/' \ + -e '/^delivered_at=/d' -e '/^delivered_obligation=/d' \ + "$home/state/public-followup/registry/pf-race" > "$home/race-open" + mv "$home/race-open" "$home/state/public-followup/registry/pf-race" + chmod 600 "$home/state/public-followup/registry/pf-race" + + seed_commitment "$home" pf-race-other req-race-other discord main work-race-other + + FM_RACE_HOME="$home" FM_RACE_ROOT="$ROOT" bash -c ' + . "$FM_RACE_ROOT/bin/fm-public-followup-lib.sh" + fm_pf_registry_lock_acquire "$FM_RACE_HOME/state" pf-race || exit 1 + : > "$FM_RACE_HOME/lock-ready" + while [ ! -e "$FM_RACE_HOME/release-lock" ]; do sleep 0.02; done + sleep 0.1 + mkdir -p "$FM_RACE_HOME/state/public-followup/retired" + printf "reason=concurrent close\nretired_at=2026-08-01T00:00:00Z\n" \ + > "$FM_RACE_HOME/state/public-followup/retired/pf-race" + chmod 600 "$FM_RACE_HOME/state/public-followup/retired/pf-race" + rm -f "$FM_RACE_HOME/state/public-followup/registry/pf-race" + fm_pf_registry_lock_release "$FM_RACE_HOME/state" pf-race + ' & + locker_pid=$! + for _ in $(seq 1 100); do [ -e "$home/lock-ready" ] && break; sleep 0.02; done + [ -e "$home/lock-ready" ] || fail "race locker did not start" + + real_tasks=$(command -v tasks-axi) + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +"$REAL_TASKS_AXI" "$@" +rc=$? +if [ "$1" = public-followup ] && [ "$2" = list ]; then + : > "$PENDING_LISTED" +fi +exit "$rc" +SH + chmod +x "$home/fakebin/tasks-axi" + REAL_TASKS_AXI="$real_tasks" PENDING_LISTED="$home/pending-listed" \ + run_pf "$home" pending > "$home/pending-race.out" 2>&1 & + pending_pid=$! + for _ in $(seq 1 100); do [ -e "$home/pending-listed" ] && break; sleep 0.02; done + [ -e "$home/pending-listed" ] || fail "pending did not snapshot the backlog" + : > "$home/release-lock" + wait "$locker_pid" || fail "race retirement failed" + wait "$pending_pid" || rc=$? + [ "$rc" -eq 0 ] || fail "pending aborted on concurrent retirement: $(cat "$home/pending-race.out")" + assert_grep 'unresolved pf-race-other ' "$home/pending-race.out" \ + "pending must continue surfacing unrelated loops after concurrent retirement: $(cat "$home/pending-race.out")" + pass "pending skips a registration retired during settlement" +} + +test_retire_reason_closes_the_open_loop() { + local home log out registry_file receipt_mode + home=$(make_home retire-reason) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-retire req-retire discord main work-retire + emit_terminal "$home" "$home" pf-retire main work-retire >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-retire >/dev/null || fail "deliver failed" + run_pf "$home" pending | grep -q '^open-loop pf-retire ' \ + || fail "pending must show the delivered open loop" + expect_failure "retire without --reason must refuse" run_pf "$home" retire pf-retire + assert_contains "$EXPECT_OUT" "--reason" "the refusal must name the required reason" + assert_present "$home/state/public-followup/registry/pf-retire" \ + "a reason-less retire must keep the registration" + registry_file="$home/state/public-followup/registry/pf-retire" + cat > "$home/fakebin/rm" <> "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$registry" + out=$(run_pf "$home2" pending) + assert_contains "$out" "open-loop pf-keep" "pending must keep a delivered registration as an open loop" + assert_grep 'state=delivered' "$registry" \ + "pending must repair a settled registration left open after legacy cleanup failed" + pass "retention creates no false teardown refusal and pending no longer prunes" +} + +test_expiry_escalation_uses_now_override() { + local home out exp now_closing now_expired registry tmp + home=$(make_home expiry-window) + seed_repro_commitment "$home" pf-exp req-exp main work-exp + exp=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' '2026-08-28T01:12:00Z' +%s 2>/dev/null) \ + || exp=$(date -u -d '2026-08-28T01:12:00Z' +%s) + now_closing=$((exp - 3600)) + now_expired=$((exp + 60)) + out=$(FMX_NOW_OVERRIDE="$now_expired" run_pf "$home" pending) + assert_contains "$out" "unresolved pf-exp" "an owed reply must remain listed after expiry" + assert_contains "$out" "can no longer be reached" \ + "an expired unresolved reply must escalate the unreachable thread" + assert_contains "$out" "captain decision" \ + "an expired unresolved reply must name the captain call" + "$EMIT" --home "$home" --obligation pf-exp --relation rel-code \ + --source-home main --work-id work-exp --generation 1 \ + --outcome report-ready --deliverable report_path=data/x/report.md \ + --outcome-text 'Reproduced.' >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-exp >/dev/null || fail "deliver failed" + out=$(FMX_NOW_OVERRIDE="$now_closing" run_pf "$home" pending) + assert_contains "$out" "open-loop pf-exp" "closing window must still list the loop" + assert_contains "$out" "DEADLINE:" "a window under 48 hours must escalate" + assert_contains "$out" "under 48 hours" "the closing wording must name the remaining window" + out=$(FMX_NOW_OVERRIDE="$now_expired" run_pf "$home" pending) + assert_contains "$out" "can no longer be reached" "a past expiry must name the unreachable thread" + assert_contains "$out" "captain decision" "a past expiry is a captain call" + FMX_NOW_OVERRIDE="$now_expired" expect_failure "rechain past expiry must refuse" \ + run_pf "$home" rechain pf-exp-next --from pf-exp --work-home main --work-id work-next --expected pr-merged + assert_contains "$EXPECT_OUT" "can no longer be reached" "rechain must name the closed window" + registry="$home/state/public-followup/registry/pf-exp" + tmp="$registry.tmp" + awk ' + /^followup_expires_at=/ { print "followup_expires_at=not-a-time"; next } + { print } + ' "$registry" > "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$registry" + expect_failure "rechain with an unknown expiry window must refuse" \ + run_pf "$home" rechain pf-exp-next --from pf-exp --work-home main --work-id work-next --expected pr-merged + assert_contains "$EXPECT_OUT" "thread window cannot be checked" \ + "rechain must fail closed when its retained expiry cannot be parsed" + pass "expiry escalation is pinned by FMX_NOW_OVERRIDE" +} + +test_brief_fails_without_typed_deliverable_keys() { + local home real_tasks invalid + home=$(make_home brief-keys) + seed_commitment "$home" pf-brief req-brief discord main work-brief + real_tasks=$(command -v tasks-axi) + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${FAKE_INVALID_KEYS:-}" = unreadable ]; then + exit 69 +fi +"$REAL_TASKS_AXI" "$@" | jq --argjson invalid "$FAKE_INVALID_KEYS" ' + if .public_followups then + .public_followups |= map( + if .id == "pf-brief" then + .public_followup.expected_final.required_deliverables = $invalid + else . end) + else . end' +SH + chmod +x "$home/fakebin/tasks-axi" + REAL_TASKS_AXI="$real_tasks" FAKE_INVALID_KEYS=unreadable expect_failure \ + "brief must fail when typed deliverable keys cannot be read" \ + run_pf "$home" brief pf-brief + assert_contains "$EXPECT_OUT" "could not read public-followup obligation" \ + "brief must explain why it cannot produce executable instructions" + assert_not_contains "$EXPECT_OUT" "=" \ + "brief must never substitute a generic deliverable placeholder" + + for invalid in '["pr_url",7]' '["pr_url",""]' '["PR_URL"]' '["pr-url"]'; do + REAL_TASKS_AXI="$real_tasks" FAKE_INVALID_KEYS="$invalid" expect_failure \ + "brief must reject an invalid required deliverable array" \ + run_pf "$home" brief pf-brief + assert_contains "$EXPECT_OUT" "no readable required deliverable keys" \ + "brief must reject the complete contract when any key is invalid" + assert_not_contains "$EXPECT_OUT" "--deliverable pr_url=" \ + "brief must not emit a partial contract from an invalid key array" + done + pass "brief fails explicitly when typed deliverable keys are unavailable" +} + +test_prechange_registration_is_open_and_unrechainable() { + local home file out + home=$(make_home prechange) + mkdir -p "$home/state/public-followup/registry" + chmod 700 "$home/state/public-followup" "$home/state/public-followup/registry" + file="$home/state/public-followup/registry/pf-legacy" + printf 'obligation_id=pf-legacy\nrelation_id=rel-code\nwork_home=main\nwork_id=work-legacy\ngeneration=1\nplatform=discord\nrequest_id=req-legacy\n' \ + > "$file" + chmod 600 "$file" + out=$(run_pf "$home" pending) || fail "pending must not crash on a pre-change registration" + assert_contains "$out" "open-loop pf-legacy" "a pre-change record is an open loop" + assert_contains "$out" "unrechainable" "a pre-change record must be reported un-rechainable" + expect_failure "rechain of a pre-change record must refuse" \ + run_pf "$home" rechain pf-new --from pf-legacy --work-home main --work-id work-next --expected pr-merged + case "$EXPECT_OUT" in + *un-rechainable*) ;; + *'not state=delivered'*) ;; + *) fail "rechain must refuse a pre-change record without crashing: $EXPECT_OUT" ;; + esac + printf 'state=delivered\ndelivered_at=2026-08-21T00:00:00Z\n' >> "$file" + expect_failure "delivered pre-change record without context is un-rechainable" \ + run_pf "$home" rechain pf-new --from pf-legacy --work-home main --work-id work-next --expected pr-merged + assert_contains "$EXPECT_OUT" "un-rechainable" "missing request_context_b64 must be named" + pass "pre-change registrations are open loops and un-rechainable, never a crash" +} + +test_x_request_teardown_warns_when_final_unposted() { + local home rc + home=$(make_home xreq-warn) + fm_write_meta "$home/state/linked-task.meta" \ + "window=firstmate:fm-linked-task" \ + "worktree=$home/projects/gone" \ + "project=$home/projects/sample" \ + "kind=ship" \ + "mode=local-only" \ + "x_request=req-legacy-final" + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" linked-task \ + > "$home/td.out" 2> "$home/td.err" || rc=$? + [ "$rc" -eq 0 ] || fail "legacy-link warning must not block teardown (rc=$rc)" + assert_grep "still carries an unreconciled Relay request link (req-legacy-final) on its task record" "$home/td.err" \ + "teardown must report the remaining link without claiming the post failed" + assert_no_grep "never posted" "$home/td.err" \ + "a remaining legacy link must not be treated as proof that no post landed" + pass "teardown reports an unreconciled legacy Relay link" +} + +test_secondmate_promotion_uses_teardown_parent_resolution() { + local parent stale child remote_child out + parent=$(make_home promote-parent) + stale=$(make_home promote-stale-parent) + child=$(make_home promote-child relay-off) + printf '%s\n' mate > "$child/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' \ + "$stale" > "$child/.fm-secondmate-parent" + printf -- '- mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-08-21)\n' \ + "$child" > "$parent/data/secondmates.md" + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + mkdir -p "$parent/state/public-followup/registry" "$stale/state/public-followup/registry" + printf 'obligation_id=pf-valid\nwork_home=secondmate:mate\nwork_id=promote-legacy\nstate=delivered\n' \ + > "$parent/state/public-followup/registry/pf-valid" + printf 'obligation_id=pf-stale\nwork_home=secondmate:mate\nwork_id=promote-conflict\nstate=delivered\n' \ + > "$stale/state/public-followup/registry/pf-stale" + chmod 600 "$parent/state/public-followup/registry/pf-valid" \ + "$stale/state/public-followup/registry/pf-stale" + + fm_write_meta "$child/state/promote-conflict.meta" \ + "window=firstmate:fm-promote-conflict" "kind=scout" + out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + "$PROMOTE" promote-conflict --mode local-only --yolo off 2>&1) \ + || fail "promotion must not block on conflicting parent bindings: $out" + assert_contains "$out" "promoted promote-conflict to ship" \ + "parent-resolution trouble must never refuse the kind flip" + assert_contains "$out" "could not resolve the consent-holding parent home" \ + "conflicting live and durable bindings must warn" + assert_not_contains "$out" "--from pf-stale" \ + "a stale durable parent must not produce a rechain hint" + + rm -f "$child/.fm-secondmate-parent" + fm_write_meta "$child/state/promote-legacy.meta" \ + "window=firstmate:fm-promote-legacy" "kind=scout" + out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + "$PROMOTE" promote-legacy --mode local-only --yolo off 2>&1) \ + || fail "legacy parent recovery must not block promotion: $out" + assert_contains "$out" "next: FM_HOME=" \ + "a recovered legacy parent must identify the consent-holding home" + assert_contains "$out" "--from pf-valid --work-home secondmate:mate --work-id promote-legacy" \ + "legacy parent recovery must print the rechain hint" + + remote_child=$(make_home promote-remote-child relay-off) + printf '%s\n' remote-mate > "$remote_child/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=remote\nparent_host=remote.example\n' \ + > "$remote_child/.fm-secondmate-parent" + printf 'FMX_PAIRING_TOKEN=child-local-token\n' > "$remote_child/.env" + fm_write_meta "$remote_child/state/promote-remote.meta" \ + "window=firstmate:fm-promote-remote" "kind=scout" + out=$(PATH="$remote_child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$remote_child" \ + FM_STATE_OVERRIDE="$remote_child/state" \ + "$PROMOTE" promote-remote --mode local-only --yolo off 2>&1) \ + || fail "a remote parent route must not block promotion: $out" + assert_contains "$out" "promoted promote-remote to ship" \ + "an unresolved remote parent must never refuse the kind flip" + assert_contains "$out" "could not resolve the consent-holding parent home" \ + "a remote route with a local Relay token must warn as teardown does" + pass "secondmate promotion matches teardown parent resolution" +} + test_outcome_text_is_bounded_without_corrupting_characters test_restart_e2e_delivers_exactly_once test_duplicate_event_and_replay_are_noops @@ -1355,3 +2233,23 @@ test_exhausted_binding_is_not_retried test_relay_poll_stays_inert_and_surfaces_once test_session_start_surfaces_only_when_owed test_typed_records_exclude_raw_public_material +test_dropped_baton_now_surfaces_open_loop +test_control_registered_followon_is_guarded +test_rechain_delivers_second_post_on_same_thread +test_rechain_resumes_after_partial_add +test_rechain_claims_delivered_source_once +test_failed_rechain_retirement_keeps_source_claimed +test_registration_replay_preserves_delivery_and_retirement +test_redelivery_does_not_report_retired_loop_open +test_retire_after_secondmate_home_removal +test_retire_refuses_unbound_existing_secondmate +test_retire_refuses_reassigned_secondmate_home +test_rechain_refuses_unclaimed_existing_destination +test_pending_skips_concurrent_retirement +test_retire_reason_closes_the_open_loop +test_retention_creates_no_false_teardown_refusal +test_expiry_escalation_uses_now_override +test_brief_fails_without_typed_deliverable_keys +test_prechange_registration_is_open_and_unrechainable +test_x_request_teardown_warns_when_final_unposted +test_secondmate_promotion_uses_teardown_parent_resolution