From 59c5ab117d3cb9258e1be35d86ca247524a9352f Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 13:57:44 -0700 Subject: [PATCH 01/18] fix: keep Relay public loops open until retire Delivering a promised-final reply was deleting the only record that tied a public thread to later work, so a follow-on ship silently owed no closing reply. Retain the registration after delivery, rechain follow-on work onto the same thread, and make retire --reason the only close. --- .agents/skills/fmx-respond/SKILL.md | 18 +- AGENTS.md | 2 +- bin/fm-backlog-handoff.sh | 14 +- bin/fm-promote.sh | 39 ++++ bin/fm-public-followup-lib.sh | 123 +++++++++- bin/fm-public-followup.sh | 334 +++++++++++++++++++++++---- bin/fm-session-start.sh | 9 +- bin/fm-teardown.sh | 17 ++ docs/architecture.md | 2 +- docs/configuration.md | 4 +- docs/verification/public-followup.md | 51 ++-- tests/fm-public-followup.test.sh | 299 +++++++++++++++++++++++- 12 files changed, 825 insertions(+), 87 deletions(-) diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 4b8e4b0e96..5009b2fdb4 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,23 @@ 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. - "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). + - 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..f798d581fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..b811673b2c 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -24,6 +24,10 @@ 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" MODE= YOLO= @@ -123,3 +127,38 @@ 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 ''" + +# After a scout's promised-final has been delivered, the same task id is still +# bound but the expected outcome was report-ready. Print the rechain line the +# consent-holding home must run; a secondmate home cannot register or post. +promote_print_rechain_hint() { + local consent_state=$1 work_home=$2 task_id=$3 id + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$consent_state" "$id" state)" = delivered ] || continue + echo "next: bin/fm-public-followup.sh rechain --from $id --work-home $work_home --work-id $task_id --expected pr-merged" + done </dev/null || true) + if fm_secondmate_parent_record_parse "$FM_HOME/.fm-secondmate-parent" \ + && [ "$FM_SECONDMATE_PARENT_ROUTE" = local ] \ + && [ -n "$FM_SECONDMATE_PARENT_HOME" ] \ + && [ -d "$FM_SECONDMATE_PARENT_HOME/state" ] \ + && fm_pf_relay_active "$FM_SECONDMATE_PARENT_HOME"; then + while IFS= read -r PROMOTE_LOOP_ID; do + [ -n "$PROMOTE_LOOP_ID" ] || continue + [ "$(fm_pf_registry_get "$FM_SECONDMATE_PARENT_HOME/state" "$PROMOTE_LOOP_ID" state)" = delivered ] || continue + echo "warning: the parent home holds an open public loop for this work; it must rechain (this home cannot register or post)." + echo "next: FM_HOME=$(printf '%q' "$FM_SECONDMATE_PARENT_HOME") bin/fm-public-followup.sh rechain --from $PROMOTE_LOOP_ID --work-home secondmate:$PROMOTE_MATE_ID --work-id $ID --expected pr-merged" + done </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. +# 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 @@ -107,6 +114,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 +236,105 @@ $(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 < : 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 + fm_pf_slug_valid "$id" || return 1 + [ -n "$delivered_at" ] || return 1 + file="$(fm_pf_registry_dir "$state")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + 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 +} + # --- 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..3bdbcb6205 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -42,7 +42,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,39 +55,56 @@ # 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. +# +# 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, reconciliation, # delivery, cleanup guards, and retirement; `active` and `brief` only inspect @@ -110,7 +128,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. @@ -124,7 +142,7 @@ pf_cleanup_temp_files() { } trap pf_cleanup_temp_files 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,6 +251,14 @@ 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 followup_expires_at request_json request_context_b64 + 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 + local mkdir_target 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 @@ -240,8 +266,9 @@ cmd_register() { || 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' \ + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%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_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 @@ -252,7 +279,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 +291,27 @@ cmd_brief() { work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) generation=$(fm_pf_registry_get "$STATE" "$id" generation) + outcome='' + # Two backslashes so the unquoted brief heredoc emits a single continuation. + # shellcheck disable=SC1003 + deliverable_flags=' --deliverable = \\' + if command -v jq >/dev/null 2>&1 && command -v tasks-axi >/dev/null 2>&1 \ + && payload=$(obligation_json "$id") && [ -n "$payload" ]; then + outcome=$(pf_field "$payload" '.public_followup.expected_final.type') + [ -n "$outcome" ] || outcome='' + keys=$(printf '%s' "$payload" | jq -r '.public_followup.expected_final.required_deliverables[]? // empty' 2>/dev/null) + if [ -n "$keys" ]; then + deliverable_flags= + while IFS= read -r key; do + [ -n "$key" ] || continue + deliverable_flags="${deliverable_flags} --deliverable ${key}= \\\\ +" + done < \\ - --deliverable = \\ + --outcome $outcome \\ +$deliverable_flags --outcome-text '' Do not post anything publicly yourself and do not look for the public thread: @@ -426,10 +474,50 @@ 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_open_loop() { + local id=$1 payload=$2 request platform summary delivered expires window ctx note + 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 + window=$(fm_pf_followup_window_class "$expires") + case "$window" in + expired) + note="DEADLINE: thread can no longer be reached (window closed ${expires:-unknown}); this needs a captain decision" + ;; + closing) + note="DEADLINE: window closes ${expires:-unknown} (under 48 hours)" + ;; + *) + note= + ;; + esac + 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 + [ -z "$note" ] || printf ' %s\n' "$note" + 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 printed=0 loop_state settled # 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 +548,23 @@ 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 + # 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 - rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + 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 +572,9 @@ 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" + if ! fm_pf_registry_rechainable "$STATE" "$id"; then + printf ' unrechainable: pre-change registration lacks request_context_b64\n' + fi printed=1 done </dev/null || true + mark_loop_delivered "$id" printf 'already delivered %s state=%s\n' "$id" "$delivery" + print_loop_open_disposition "$id" "$request" return 0 ;; ready|retry-due|context-blocked|unknown|partial) @@ -743,8 +848,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 + mark_loop_delivered "$id" printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" + 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 @@ -800,8 +906,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 + mark_loop_delivered "$id" printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" + print_loop_open_disposition "$id" "$request" } # --- subcommand: guard-work ------------------------------------------------- @@ -847,21 +954,145 @@ 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 + + local src_file loop_state expires window ctx + 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") + if [ "$window" = expired ]; then + 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 + fi + + 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 + + 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" + + 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 + 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" 1 + + 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'" 1 + + 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 [ -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 payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 @@ -880,7 +1111,7 @@ cmd_retire() { 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" + printf 'retired %s reason=%s\n' "$id" "$reason" } # --- dispatch --------------------------------------------------------------- @@ -901,6 +1132,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..6450d5e1b2 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2351,6 +2351,23 @@ 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, but tearing +# down a task that still carries x_request= means its --final was never posted. +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 a Relay request link ($X_REQUEST) whose final public reply was never posted." >&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..8a84e7a88b 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 nothing of its own beyond the activation gate, a private terminal-event inbox, the idempotent delivery sequence, and explicit loop closure: delivering a final stamps the registration delivered and leaves the public loop open until `retire`. 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..0bfdb21a82 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -489,7 +489,7 @@ That promise is a typed `kind=public-followup` obligation owned entirely by `tas `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`. 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 public-safe 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, 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,7 +499,7 @@ 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. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md index 3bad5a605d..cbdb35294e 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,26 @@ 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 - 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 - pre-change registrations are open loops and un-rechainable, never a crash +ok - teardown warns when a legacy Relay link is torn down with its final unposted ``` -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` is the only close. +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 +89,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 +102,4 @@ 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 one lifecycle integration is `bin/fm-teardown.sh`'s owed-reply refusal plus the non-blocking open-loop and legacy `x_request=` warnings, which run before any backend command and key only on the task id and registration records, 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..08c7c5b615 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -150,6 +150,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} @@ -1165,6 +1193,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 +1326,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 +1359,259 @@ 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 + 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" + 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_retire_reason_closes_the_open_loop() { + local home log out + 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" + out=$(run_pf "$home" retire pf-retire --reason "the public loop is finished") \ + || fail "retire --reason failed" + assert_contains "$out" "retired pf-retire reason=the public loop is finished" \ + "retire must record the reason" + assert_absent "$home/state/public-followup/registry/pf-retire" \ + "retire is the only removal" + out=$(run_pf "$home" pending || true) + case "$out" in + *pf-retire*) fail "pending must stop listing a retired loop: $out" ;; + esac + pass "retire --reason closes the loop and drops the open-loop line" +} + +test_retention_creates_no_false_teardown_refusal() { + local home home2 rc out + home=$(make_home retain-teardown) + seed_commitment "$home" pf-retain req-retain discord main ship-retain + fm_write_meta "$home/state/ship-retain.meta" \ + "window=firstmate:fm-ship-retain" \ + "worktree=$home/projects/gone" \ + "project=$home/projects/sample" \ + "harness=codex" \ + "kind=ship" \ + "mode=no-mistakes" + emit_terminal "$home" "$home" pf-retain main ship-retain >/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-retain >/dev/null || fail "delivery failed" + run_pf "$home" guard-work main ship-retain \ + || fail "guard-work must pass for the work whose reply already landed" + assert_present "$home/state/public-followup/registry/pf-retain" \ + "the delivered registration must still be present" + 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" ship-retain \ + > "$home/td.out" 2> "$home/td.err" || rc=$? + [ "$rc" -eq 0 ] || fail "teardown must proceed with a retained delivered registration (rc=$rc)" + case "$(cat "$home/td.err")" in + *"still owes a public reply"*) fail "retention must not create a false public-reply refusal" ;; + esac + # pending still sees the loop because teardown does not retire it + home2=$(make_home retain-pending) + seed_commitment "$home2" pf-keep req-keep discord main work-keep + emit_terminal "$home2" "$home2" pf-keep main work-keep >/dev/null || fail "emit2 failed" + run_pf "$home2" consume >/dev/null || fail "consume2 failed" + FAKE_CURL_LOG="$home2/curl.log" run_pf "$home2" deliver pf-keep >/dev/null || fail "deliver2 failed" + out=$(run_pf "$home2" pending) + assert_contains "$out" "open-loop pf-keep" "pending must keep a delivered registration as an open loop" + 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 + home=$(make_home expiry-window) + seed_repro_commitment "$home" pf-exp req-exp main work-exp + "$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" + 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_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" + pass "expiry escalation is pinned by FMX_NOW_OVERRIDE" +} + +test_prechange_registration_is_open_and_unrechainable() { + local home file out + home=$(make_home prechange) + mkdir -p "$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 a Relay request link (req-legacy-final)" "$home/td.err" \ + "teardown must warn when x_request= is still present" + pass "teardown warns when a legacy Relay link is torn down with its final unposted" +} + test_outcome_text_is_bounded_without_corrupting_characters test_restart_e2e_delivers_exactly_once test_duplicate_event_and_replay_are_noops @@ -1355,3 +1644,11 @@ 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_retire_reason_closes_the_open_loop +test_retention_creates_no_false_teardown_refusal +test_expiry_escalation_uses_now_override +test_prechange_registration_is_open_and_unrechainable +test_x_request_teardown_warns_when_final_unposted From d96ef763d9be99177607af2f5cff6f031a542781 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 14:03:27 -0700 Subject: [PATCH 02/18] no-mistakes(review): Propagate public follow-up registration removal failures --- bin/fm-public-followup.sh | 8 ++++++-- tests/fm-public-followup.test.sh | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 3bdbcb6205..b9f6e2fe9f 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -1077,7 +1077,7 @@ cmd_rechain() { # --- subcommand: retire ----------------------------------------------------- cmd_retire() { - local id=${1:-} force=0 reason='' payload delivery task_state + local id=${1:-} force=0 reason='' payload delivery task_state registry_file [ -n "$id" ] || { usage; exit 2; } shift while [ "$#" -gt 0 ]; do @@ -1110,7 +1110,11 @@ 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 + registry_file="$(fm_pf_registry_dir "$STATE")/$id" + if ! rm -f -- "$registry_file" 2>/dev/null \ + || [ -e "$registry_file" ] || [ -L "$registry_file" ]; then + die "could not remove registration for '$id'; the public loop remains open" 1 + fi printf 'retired %s reason=%s\n' "$id" "$reason" } diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 08c7c5b615..683468b57b 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1475,7 +1475,7 @@ test_rechain_delivers_second_post_on_same_thread() { } test_retire_reason_closes_the_open_loop() { - local home log out + local home log out registry_file home=$(make_home retire-reason) log="$home/curl.log"; : > "$log" seed_commitment "$home" pf-retire req-retire discord main work-retire @@ -1488,6 +1488,22 @@ test_retire_reason_closes_the_open_loop() { 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" < Date: Fri, 21 Aug 2026 15:51:47 -0700 Subject: [PATCH 03/18] no-mistakes(review): Persist retire receipts and align parent resolution --- bin/fm-promote.sh | 111 ++++++++++++++++++++++++------- bin/fm-public-followup.sh | 7 +- bin/fm-teardown.sh | 5 +- tests/fm-public-followup.test.sh | 71 ++++++++++++++++++-- 4 files changed, 161 insertions(+), 33 deletions(-) diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index b811673b2c..f3cf0a6633 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -28,6 +28,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$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= @@ -128,37 +130,98 @@ 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 ''" -# After a scout's promised-final has been delivered, the same task id is still -# bound but the expected outcome was report-ready. Print the rechain line the -# consent-holding home must run; a secondmate home cannot register or post. promote_print_rechain_hint() { - local consent_state=$1 work_home=$2 task_id=$3 id + 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_state" "$id" state)" = delivered ] || continue - echo "next: bin/fm-public-followup.sh rechain --from $id --work-home $work_home --work-id $task_id --expected pr-merged" + [ "$(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) - if fm_secondmate_parent_record_parse "$FM_HOME/.fm-secondmate-parent" \ - && [ "$FM_SECONDMATE_PARENT_ROUTE" = local ] \ - && [ -n "$FM_SECONDMATE_PARENT_HOME" ] \ - && [ -d "$FM_SECONDMATE_PARENT_HOME/state" ] \ - && fm_pf_relay_active "$FM_SECONDMATE_PARENT_HOME"; then - while IFS= read -r PROMOTE_LOOP_ID; do - [ -n "$PROMOTE_LOOP_ID" ] || continue - [ "$(fm_pf_registry_get "$FM_SECONDMATE_PARENT_HOME/state" "$PROMOTE_LOOP_ID" state)" = delivered ] || continue - echo "warning: the parent home holds an open public loop for this work; it must rechain (this home cannot register or post)." - echo "next: FM_HOME=$(printf '%q' "$FM_SECONDMATE_PARENT_HOME") bin/fm-public-followup.sh rechain --from $PROMOTE_LOOP_ID --work-home secondmate:$PROMOTE_MATE_ID --work-id $ID --expected pr-merged" - done </dev/null \ || [ -e "$registry_file" ] || [ -L "$registry_file" ]; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 6450d5e1b2..c84669d363 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2361,11 +2361,10 @@ if [ "$KIND" = ship ] && [ -n "$PR_URL" ] \ 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, but tearing -# down a task that still carries x_request= means its --final was never posted. +# 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 a Relay request link ($X_REQUEST) whose final public reply was never posted." >&2 + 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 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 683468b57b..b1f0c80684 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) @@ -1475,7 +1476,7 @@ test_rechain_delivers_second_post_on_same_thread() { } test_retire_reason_closes_the_open_loop() { - local home log out registry_file + 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 @@ -1507,7 +1508,18 @@ EOF out=$(run_pf "$home" retire pf-retire --reason "the public loop is finished") \ || fail "retire --reason failed" assert_contains "$out" "retired pf-retire reason=the public loop is finished" \ - "retire must record the reason" + "retire must report the reason" + assert_present "$home/state/public-followup/retired/pf-retire" \ + "retire must persist a private receipt before removing the registration" + assert_grep 'reason=the public loop is finished' \ + "$home/state/public-followup/retired/pf-retire" \ + "the retirement receipt must preserve the reason" + assert_grep 'retired_at=' "$home/state/public-followup/retired/pf-retire" \ + "the retirement receipt must preserve its timestamp" + receipt_mode=$(stat -f %Lp "$home/state/public-followup/retired/pf-retire" 2>/dev/null \ + || stat -c %a "$home/state/public-followup/retired/pf-retire" 2>/dev/null) \ + || fail "could not inspect the retirement receipt mode" + [ "$receipt_mode" = 600 ] || fail "the retirement receipt must be private" assert_absent "$home/state/public-followup/registry/pf-retire" \ "retire is the only removal" out=$(run_pf "$home" pending || true) @@ -1623,9 +1635,57 @@ test_x_request_teardown_warns_when_final_unposted() { 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 a Relay request link (req-legacy-final)" "$home/td.err" \ - "teardown must warn when x_request= is still present" - pass "teardown warns when a legacy Relay link is torn down with its final unposted" + 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 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" + pass "secondmate promotion matches teardown parent resolution" } test_outcome_text_is_bounded_without_corrupting_characters @@ -1668,3 +1728,4 @@ test_retention_creates_no_false_teardown_refusal test_expiry_escalation_uses_now_override test_prechange_registration_is_open_and_unrechainable test_x_request_teardown_warns_when_final_unposted +test_secondmate_promotion_uses_teardown_parent_resolution From 2b566dd654e89fd96647a6ccd7ff052e8b9fe436 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 15:56:22 -0700 Subject: [PATCH 04/18] no-mistakes(review): Make rechain resumable after partial obligation creation --- bin/fm-public-followup.sh | 63 +++++++++++++++++++++++++++----- tests/fm-public-followup.test.sh | 57 +++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 785af75df6..643d4a3903 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -1057,16 +1057,59 @@ cmd_rechain() { '{relation_id:"rel-1", work_ref:{home_id:$h, task_id:$w}, role:"fulfills", required:true, generation:1}' > "$relation_file" - 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 - 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" 1 - - 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'" 1 + local existing relation_count new_registry + existing=$(obligation_json "$new_id") \ + || die "could not read the backlog through tasks-axi" 1 + 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 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index b1f0c80684..f1245ce091 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1475,6 +1475,62 @@ test_rechain_delivers_second_post_on_same_thread() { 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_retire_reason_closes_the_open_loop() { local home log out registry_file receipt_mode home=$(make_home retire-reason) @@ -1723,6 +1779,7 @@ 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_retire_reason_closes_the_open_loop test_retention_creates_no_false_teardown_refusal test_expiry_escalation_uses_now_override From bb07c0c8441d57b4eac3fc7cd3f06c872dc9f393 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 16:19:10 -0700 Subject: [PATCH 05/18] no-mistakes(review): Repair follow-up state, briefs, and expiry escalation --- bin/fm-public-followup.sh | 74 ++++++++++++++++++-------------- tests/fm-public-followup.test.sh | 46 +++++++++++++++++--- 2 files changed, 83 insertions(+), 37 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 643d4a3903..7ba1d84c5d 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -291,26 +291,27 @@ cmd_brief() { work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) generation=$(fm_pf_registry_get "$STATE" "$id" generation) - outcome='' - # Two backslashes so the unquoted brief heredoc emits a single continuation. - # shellcheck disable=SC1003 - deliverable_flags=' --deliverable = \\' - if command -v jq >/dev/null 2>&1 && command -v tasks-axi >/dev/null 2>&1 \ - && payload=$(obligation_json "$id") && [ -n "$payload" ]; then - outcome=$(pf_field "$payload" '.public_followup.expected_final.type') - [ -n "$outcome" ] || outcome='' - keys=$(printf '%s' "$payload" | jq -r '.public_followup.expected_final.required_deliverables[]? // empty' 2>/dev/null) - if [ -n "$keys" ]; then - deliverable_flags= - while IFS= read -r key; do - [ -n "$key" ] || continue - deliverable_flags="${deliverable_flags} --deliverable ${key}= \\\\ + 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) + | .[] | select(type == "string" and length > 0)' 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 < : 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 window ctx note + 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) @@ -492,32 +507,20 @@ print_open_loop() { summary=$(printf '%s' "$ctx" | fm_pf_b64_decode | jq -r '.public_safe_summary // empty' 2>/dev/null | fm_pf_clean_outcome_text) fi fi - window=$(fm_pf_followup_window_class "$expires") - case "$window" in - expired) - note="DEADLINE: thread can no longer be reached (window closed ${expires:-unknown}); this needs a captain decision" - ;; - closing) - note="DEADLINE: window closes ${expires:-unknown} (under 48 hours)" - ;; - *) - note= - ;; - esac 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 - [ -z "$note" ] || printf ' %s\n' "$note" + 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 loop_state settled + local listing id payload delivery task_state summary platform request expires printed=0 loop_state settled # 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 \ @@ -558,6 +561,10 @@ cmd_pending() { settled=1 fi if [ "$settled" -eq 1 ]; then + if [ "$loop_state" != delivered ]; then + fm_pf_registry_stamp_delivered "$STATE" "$id" "$(now_rfc3339)" \ + || die "could not stamp settled registration '$id' as delivered" 1 + fi # 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 @@ -572,6 +579,9 @@ 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 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index f1245ce091..c1b84fe1e2 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1586,7 +1586,7 @@ EOF } test_retention_creates_no_false_teardown_refusal() { - local home home2 rc out + local home home2 rc out registry tmp home=$(make_home retain-teardown) seed_commitment "$home" pf-retain req-retain discord main ship-retain fm_write_meta "$home/state/ship-retain.meta" \ @@ -1618,8 +1618,16 @@ test_retention_creates_no_false_teardown_refusal() { emit_terminal "$home2" "$home2" pf-keep main work-keep >/dev/null || fail "emit2 failed" run_pf "$home2" consume >/dev/null || fail "consume2 failed" FAKE_CURL_LOG="$home2/curl.log" run_pf "$home2" deliver pf-keep >/dev/null || fail "deliver2 failed" + registry="$home2/state/public-followup/registry/pf-keep" + tmp="$registry.tmp" + grep -v -E '^(state|delivered_at|delivered_obligation)=' "$registry" > "$tmp" + printf 'state=open\n' >> "$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" } @@ -1627,16 +1635,22 @@ test_expiry_escalation_uses_now_override() { local home out exp now_closing now_expired 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" - 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_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" @@ -1650,10 +1664,31 @@ test_expiry_escalation_uses_now_override() { pass "expiry escalation is pinned by FMX_NOW_OVERRIDE" } +test_brief_fails_without_typed_deliverable_keys() { + local home real_tasks + 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 +exit 69 +SH + chmod +x "$home/fakebin/tasks-axi" + REAL_TASKS_AXI="$real_tasks" 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" + 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" @@ -1783,6 +1818,7 @@ test_rechain_resumes_after_partial_add 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 From e9aa0443b94f87d3cc243cacba670579c2b39484 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 16:23:37 -0700 Subject: [PATCH 06/18] no-mistakes(review): Serialize follow-up delivery stamps with retirement --- bin/fm-public-followup-lib.sh | 38 +++++++++++++++++++++++++++++------ bin/fm-public-followup.sh | 20 +++++++++++++----- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index c2c360a67a..7ffa56b4b4 100644 --- a/bin/fm-public-followup-lib.sh +++ b/bin/fm-public-followup-lib.sh @@ -320,19 +320,45 @@ EOF return 1 } +fm_pf_registry_lock_path() { + printf '%s/.registry-%s.lock\n' "$(fm_pf_root "$1")" "$2" +} + +fm_pf_registry_lock_acquire() { + local state=$1 id=$2 + fm_pf_slug_valid "$id" || return 1 + fmx_private_artifact_dir_prepare "$(fm_pf_root "$state")" >/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 + 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" - [ -f "$file" ] && [ ! -L "$file" ] || return 1 - 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 + 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=1 + fi + fm_pf_registry_lock_release "$state" "$id" + return "$rc" } # --- pending-event signature ------------------------------------------------ diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 7ba1d84c5d..75864f848e 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -1131,6 +1131,7 @@ cmd_rechain() { cmd_retire() { 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 @@ -1165,14 +1166,23 @@ cmd_retire() { fi retired_dir="$STATE/$FM_PF_DIRNAME/retired" retired_at=$(now_rfc3339) + registry_file="$(fm_pf_registry_dir "$STATE")/$id" + fm_pf_registry_lock_acquire "$STATE" "$id" \ + || die "could not lock registration '$id' for retirement" 1 printf 'reason=%s\nretired_at=%s\n' "$reason" "$retired_at" \ | fmx_private_artifact_publish_stdin "$retired_dir" "$id" 600 \ - || die "could not record the retirement reason for '$id'; the public loop remains open" 1 - registry_file="$(fm_pf_registry_dir "$STATE")/$id" - if ! rm -f -- "$registry_file" 2>/dev/null \ - || [ -e "$registry_file" ] || [ -L "$registry_file" ]; then - die "could not remove registration for '$id'; the public loop remains open" 1 + || 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 + fm_pf_registry_lock_release "$STATE" "$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" } From dfba629b25a0eb6ec44654e35c705ae0fd145953 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 16:31:06 -0700 Subject: [PATCH 07/18] no-mistakes(review): Serialize rechain claims and protect registration terminal states --- bin/fm-public-followup.sh | 64 +++++++++++++++++++++++++++----- tests/fm-public-followup.test.sh | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 75864f848e..8e0d033fa2 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -137,10 +137,38 @@ 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() { fm_pf_now_rfc3339; } @@ -259,18 +287,32 @@ cmd_register() { request_context_b64=$(printf '%s' "$request_json" | fm_pf_b64_encode) fi - local mkdir_target + local mkdir_target registry_state delivered_at 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\nstate=open\nfollowup_expires_at=%s\nrequest_context_b64=%s\n' \ - "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ - "$followup_expires_at" "$request_context_b64" \ - | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ + pf_registry_lock_acquire "$id" \ + || die "could not lock registration '$id'" 1 + retired_file="$STATE/$FM_PF_DIRNAME/retired/$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") + delivered_at=$(fm_pf_registry_get "$STATE" "$id" delivered_at) + if [ "$registry_state" = delivered ]; then + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\nstate=delivered\ndelivered_at=%s\nfollowup_expires_at=%s\nrequest_context_b64=%s\n' \ + "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ + "$delivered_at" "$followup_expires_at" "$request_context_b64" + else + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%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_id" "$generation" "$platform" "$request" \ + "$followup_expires_at" "$request_context_b64" + fi | 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}" @@ -1005,6 +1047,8 @@ cmd_rechain() { 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 src_file="$(fm_pf_registry_dir "$STATE")/$from" [ -f "$src_file" ] && [ ! -L "$src_file" ] \ @@ -1148,6 +1192,8 @@ cmd_retire() { 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 @@ -1167,8 +1213,6 @@ cmd_retire() { retired_dir="$STATE/$FM_PF_DIRNAME/retired" retired_at=$(now_rfc3339) registry_file="$(fm_pf_registry_dir "$STATE")/$id" - fm_pf_registry_lock_acquire "$STATE" "$id" \ - || die "could not lock registration '$id' for retirement" 1 printf 'reason=%s\nretired_at=%s\n' "$reason" "$retired_at" \ | fmx_private_artifact_publish_stdin "$retired_dir" "$id" 600 \ || retirement_rc=1 @@ -1178,7 +1222,7 @@ cmd_retire() { retirement_rc=2 fi fi - fm_pf_registry_lock_release "$STATE" "$id" + 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 ;; diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index c1b84fe1e2..0dcfdad945 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1531,6 +1531,68 @@ SH 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_registration_replay_preserves_delivery_and_retirement() { + local home log + home=$(make_home register-replay) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-register-replay req-register-replay discord main work-register-replay + emit_terminal "$home" "$home" pf-register-replay main work-register-replay >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + 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" + grep -q '^state=delivered$' "$home/state/public-followup/registry/pf-register-replay" \ + || fail "registration replay must not downgrade a delivered loop" + + 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_retire_reason_closes_the_open_loop() { local home log out registry_file receipt_mode home=$(make_home retire-reason) @@ -1815,6 +1877,8 @@ 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_registration_replay_preserves_delivery_and_retirement test_retire_reason_closes_the_open_loop test_retention_creates_no_false_teardown_refusal test_expiry_escalation_uses_now_override From 1900286c9b77e8a4c471c1ab2d18ebd0d2695ca6 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 16:35:31 -0700 Subject: [PATCH 08/18] no-mistakes(review): Avoid reporting retired delivery loops as open --- bin/fm-public-followup-lib.sh | 2 +- bin/fm-public-followup.sh | 29 ++++++++++++++++------------- tests/fm-public-followup.test.sh | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index 7ffa56b4b4..68286f9163 100644 --- a/bin/fm-public-followup-lib.sh +++ b/bin/fm-public-followup-lib.sh @@ -355,7 +355,7 @@ fm_pf_registry_stamp_delivered() { | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$state")" "$id" 600 \ || rc=$? else - rc=1 + rc=3 fi fm_pf_registry_lock_release "$state" "$id" return "$rc" diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 8e0d033fa2..fb042241a4 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -760,13 +760,15 @@ record_posted() { } # Delivery keeps the registration. Stamp it delivered and tell the caller the -# public loop is still open. Missing records are a no-op so the already-posted -# path can succeed after a prior successful stamp. +# public loop is still open. mark_loop_delivered() { - local id=$1 - [ -f "$(fm_pf_registry_dir "$STATE")/$id" ] || return 0 - fm_pf_registry_stamp_delivered "$STATE" "$id" "$(now_rfc3339)" \ - || die "could not stamp registration '$id' as delivered after the public reply landed" 1 + 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() { @@ -793,6 +795,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 @@ -817,9 +820,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 - mark_loop_delivered "$id" + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'already delivered %s state=%s\n' "$id" "$delivery" - print_loop_open_disposition "$id" "$request" + [ "$loop_retained" -eq 0 ] || print_loop_open_disposition "$id" "$request" return 0 ;; ready|retry-due|context-blocked|unknown|partial) @@ -900,9 +903,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 - mark_loop_delivered "$id" + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" - print_loop_open_disposition "$id" "$request" + [ "$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 @@ -947,7 +950,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') @@ -958,9 +961,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 - mark_loop_delivered "$id" + if mark_loop_delivered "$id"; then loop_retained=1; fi printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" - print_loop_open_disposition "$id" "$request" + [ "$loop_retained" -eq 0 ] || print_loop_open_disposition "$id" "$request" } # --- subcommand: guard-work ------------------------------------------------- diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 0dcfdad945..8e641a2d19 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1593,6 +1593,31 @@ test_registration_replay_preserves_delivery_and_retirement() { 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_reason_closes_the_open_loop() { local home log out registry_file receipt_mode home=$(make_home retire-reason) @@ -1879,6 +1904,7 @@ test_rechain_delivers_second_post_on_same_thread test_rechain_resumes_after_partial_add test_rechain_claims_delivered_source_once test_registration_replay_preserves_delivery_and_retirement +test_redelivery_does_not_report_retired_loop_open test_retire_reason_closes_the_open_loop test_retention_creates_no_false_teardown_refusal test_expiry_escalation_uses_now_override From 958ada1ba20ee4fbed3bfe7b37a16e72e4aacd92 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 16:45:31 -0700 Subject: [PATCH 09/18] no-mistakes(document): Refresh public-loop documentation and verification evidence --- .agents/skills/fmx-respond/SKILL.md | 5 ++++- bin/fm-public-followup-lib.sh | 7 ++++++- bin/fm-public-followup.sh | 9 +++++---- docs/configuration.md | 2 +- docs/verification/public-followup.md | 11 +++++++++-- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 5009b2fdb4..1a454c3fa3 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -255,7 +255,10 @@ So treat second-mate-routed Relay work as a promised final by construction: the - "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). diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index 68286f9163..c94362bebf 100644 --- a/bin/fm-public-followup-lib.sh +++ b/bin/fm-public-followup-lib.sh @@ -29,7 +29,8 @@ # no backlog scan happens. # # Private transport layout, all under /state/public-followup (mode 0700, -# created only by `fm-public-followup.sh register`): +# initialized by `fm-public-followup.sh register` and extended only by these +# public-followup commands): # registry/ registration record: the bounded public-safe # binding (obligation, relation, work ref, # generation, platform, request id) plus the loop @@ -50,6 +51,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 diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index fb042241a4..3f5bc6b83d 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 @@ -106,9 +107,9 @@ # --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, reconciliation, -# delivery, cleanup guards, and retirement; `active` and `brief` only inspect -# local state. +# 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 diff --git a/docs/configuration.md b/docs/configuration.md index 0bfdb21a82..f477a97311 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -489,7 +489,7 @@ That promise is a typed `kind=public-followup` obligation owned entirely by `tas `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`. 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 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, 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 public-safe 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 private 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. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md index cbdb35294e..fc8f436131 100644 --- a/docs/verification/public-followup.md +++ b/docs/verification/public-followup.md @@ -58,11 +58,17 @@ ok - typed public-followup records carry only public-safe summaries and delivera 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 - registration replay preserves delivered and retired loop states +ok - redelivery does not report a retired loop as open 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 warns when a legacy Relay link is torn down with its final unposted +ok - teardown reports an unreconciled legacy Relay link +ok - secondmate promotion matches teardown parent resolution ``` The restart case is the end-to-end proof of guarantee 1. @@ -71,7 +77,8 @@ It reproduces the stranded state first (work bound, no reconciled terminal resul 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` is the only close. +`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. The existing Relay mention suite (`tests/fm-x-mode.test.sh`) is unchanged by this work. From 5604b0c4f4141468ab02be77052dc9986bc75994 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:08:14 -0700 Subject: [PATCH 10/18] no-mistakes: apply CI fixes --- bin/fm-public-followup.sh | 21 +++++++++++-- tests/fm-public-followup.test.sh | 53 ++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 3f5bc6b83d..04e97f5508 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -288,7 +288,7 @@ cmd_register() { request_context_b64=$(printf '%s' "$request_json" | fm_pf_b64_encode) fi - local mkdir_target registry_state delivered_at retired_file + local mkdir_target registry_state delivered_at retired_file rechain_to 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 \ @@ -303,10 +303,12 @@ cmd_register() { fi registry_state=$(fm_pf_registry_loop_state "$STATE" "$id") delivered_at=$(fm_pf_registry_get "$STATE" "$id" delivered_at) + rechain_to=$(fm_pf_registry_get "$STATE" "$id" rechain_to) if [ "$registry_state" = delivered ]; then printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\nstate=delivered\ndelivered_at=%s\nfollowup_expires_at=%s\nrequest_context_b64=%s\n' \ "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ "$delivered_at" "$followup_expires_at" "$request_context_b64" + [ -z "$rechain_to" ] || printf 'rechain_to=%s\n' "$rechain_to" else printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%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_id" "$generation" "$platform" "$request" \ @@ -1053,7 +1055,7 @@ cmd_rechain() { pf_registry_lock_acquire "$from" \ || die "could not lock source registration '$from' for rechain" 1 - local src_file loop_state expires window ctx + local src_file loop_state expires window ctx rechain_to source_record src_file="$(fm_pf_registry_dir "$STATE")/$from" [ -f "$src_file" ] && [ ! -L "$src_file" ] \ || die "no registration for '$from' in this home" 1 @@ -1083,6 +1085,21 @@ cmd_rechain() { 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 + 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 + 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") \ diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 8e641a2d19..20f6a51f0c 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1567,6 +1567,54 @@ test_rechain_claims_delivered_source_once() { 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" </dev/null \ @@ -302,18 +302,15 @@ cmd_register() { die "public loop '$id' has already been retired and cannot be registered again" 1 fi registry_state=$(fm_pf_registry_loop_state "$STATE" "$id") - delivered_at=$(fm_pf_registry_get "$STATE" "$id" delivered_at) - rechain_to=$(fm_pf_registry_get "$STATE" "$id" rechain_to) if [ "$registry_state" = delivered ]; then - printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\nstate=delivered\ndelivered_at=%s\nfollowup_expires_at=%s\nrequest_context_b64=%s\n' \ - "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ - "$delivered_at" "$followup_expires_at" "$request_context_b64" - [ -z "$rechain_to" ] || printf 'rechain_to=%s\n' "$rechain_to" - else - printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%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_id" "$generation" "$platform" "$request" \ - "$followup_expires_at" "$request_context_b64" - fi | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ + 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_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_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" diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 20f6a51f0c..04db806919 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1616,19 +1616,45 @@ EOF } test_registration_replay_preserves_delivery_and_retirement() { - local home log + local home log registry snapshot home=$(make_home register-replay) log="$home/curl.log"; : > "$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 "consume 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" - grep -q '^state=delivered$' "$home/state/public-followup/registry/pf-register-replay" \ + 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" \ From c04eb66d92f39568e069f683f786e38632708849 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:31:24 -0700 Subject: [PATCH 12/18] no-mistakes(review): Harden public follow-up retirement and rechain races --- bin/fm-public-followup-lib.sh | 7 ++ bin/fm-public-followup.sh | 70 ++++++++++++----- tests/fm-public-followup.test.sh | 124 +++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 20 deletions(-) diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index c94362bebf..3903c01d01 100644 --- a/bin/fm-public-followup-lib.sh +++ b/bin/fm-public-followup-lib.sh @@ -103,6 +103,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 diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index b1f80591fb..5f1c75c3c9 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -297,7 +297,7 @@ cmd_register() { pf_registry_lock_acquire "$id" \ || die "could not lock registration '$id'" 1 - retired_file="$STATE/$FM_PF_DIRNAME/retired/$id" + 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 @@ -562,7 +562,7 @@ print_open_loop() { cmd_pending() { gate_or_exit - local listing id payload delivery task_state summary platform request expires printed=0 loop_state settled + 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 \ @@ -604,7 +604,12 @@ cmd_pending() { fi if [ "$settled" -eq 1 ]; then if [ "$loop_state" != delivered ]; then - fm_pf_registry_stamp_delivered "$STATE" "$id" "$(now_rfc3339)" \ + 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 # Keep the registration. Clearing a leftover legacy link is best-effort @@ -658,24 +663,32 @@ public_followup_registration_valid() { } public_followup_secondmate_home() { - local id=$1 meta home marker + local id=$1 meta_home registry_home home marker fm_pf_home_id_valid "secondmate:$id" || return 1 - meta="$STATE/$id.meta" - home=$(fmx_meta_get "$meta" home) - if [ -z "$home" ] && [ -f "$DATA/secondmates.md" ] && [ ! -L "$DATA/secondmates.md" ]; then - home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home || true) + 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 - [ -n "$home" ] || return 1 - case "$home" in /*) ;; *) return 1 ;; esac - home=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 1 - [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 1 + home=${meta_home:-$registry_home} + [ -n "$home" ] || return 3 + case "$home" in /*) ;; *) return 2 ;; esac + if [ ! -e "$home" ]; then + [ ! -L "$home" ] || return 2 + 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_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) @@ -686,7 +699,10 @@ clear_public_followup_link() { state=$STATE ;; secondmate:*) - home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 1 + rc=0 + home=$(public_followup_secondmate_home "${work_home#secondmate:}") || rc=$? + [ "$rc" -ne 3 ] || return 0 + [ "$rc" -eq 0 ] || return 1 state="$home/state" ;; *) return 1 ;; @@ -1052,7 +1068,7 @@ cmd_rechain() { 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 + 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 @@ -1090,11 +1106,21 @@ cmd_rechain() { 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 @@ -1129,9 +1155,13 @@ cmd_rechain() { '{relation_id:"rel-1", work_ref:{home_id:$h, task_id:$w}, role:"fulfills", required:true, generation:1}' > "$relation_file" - local existing relation_count new_registry - existing=$(obligation_json "$new_id") \ - || die "could not read the backlog through tasks-axi" 1 + 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" \ @@ -1228,7 +1258,7 @@ 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 - retired_dir="$STATE/$FM_PF_DIRNAME/retired" + 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" \ diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 04db806919..b786a453c5 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1692,6 +1692,127 @@ test_redelivery_does_not_report_retired_loop_open() { 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_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 i + 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 i 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 i 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) @@ -1980,6 +2101,9 @@ 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_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 From c62f94583638c030d2574d9d20fe898d814caf54 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:39:25 -0700 Subject: [PATCH 13/18] no-mistakes(review): Fail closed on unresolved secondmate retirement --- bin/fm-public-followup.sh | 25 +++++++++++++++++----- tests/fm-public-followup.test.sh | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 5f1c75c3c9..537a483894 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -280,13 +280,22 @@ 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 followup_expires_at request_json request_context_b64 + 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")" \ @@ -307,8 +316,8 @@ cmd_register() { printf 'already registered %s state=delivered\n' "$id" return 0 fi - printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%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_id" "$generation" "$platform" "$request" \ + 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 @@ -674,7 +683,7 @@ public_followup_secondmate_home() { return 2 fi home=${meta_home:-$registry_home} - [ -n "$home" ] || return 3 + [ -n "$home" ] || return 4 case "$home" in /*) ;; *) return 2 ;; esac if [ ! -e "$home" ]; then [ ! -L "$home" ] || return 2 @@ -688,7 +697,7 @@ public_followup_secondmate_home() { } clear_public_followup_link() { - local id=$1 work_home work_id home state rc + 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) @@ -702,6 +711,12 @@ clear_public_followup_link() { rc=0 home=$(public_followup_secondmate_home "${work_home#secondmate:}") || rc=$? [ "$rc" -ne 3 ] || return 0 + if [ "$rc" -eq 4 ]; then + work_home_path=$(fm_pf_registry_get "$STATE" "$id" work_home_path) + case "$work_home_path" in /*) ;; *) return 1 ;; esac + [ ! -e "$work_home_path" ] && [ ! -L "$work_home_path" ] || return 1 + return 0 + fi [ "$rc" -eq 0 ] || return 1 state="$home/state" ;; diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index b786a453c5..6f17f07e29 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1721,6 +1721,41 @@ test_retire_after_secondmate_home_removal() { 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_rechain_refuses_unclaimed_existing_destination() { local home log out home=$(make_home rechain-existing-destination) @@ -2102,6 +2137,7 @@ 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_rechain_refuses_unclaimed_existing_destination test_pending_skips_concurrent_retirement test_retire_reason_closes_the_open_loop From 5dc4cac776925b57a309b40b7e09f8f2a7b09686 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:45:34 -0700 Subject: [PATCH 14/18] no-mistakes(review): Bind secondmate cleanup to its recorded canonical home --- bin/fm-public-followup.sh | 17 +++++++++---- tests/fm-public-followup.test.sh | 43 ++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 537a483894..4ec0877e2a 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -672,7 +672,7 @@ public_followup_registration_valid() { } public_followup_secondmate_home() { - local id=$1 meta_home registry_home home marker + local id=$1 include_absent=${2:-} meta_home registry_home home marker fm_pf_home_id_valid "secondmate:$id" || return 1 meta_home=$(fmx_meta_get "$STATE/$id.meta" home) registry_home= @@ -687,6 +687,7 @@ public_followup_secondmate_home() { 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 @@ -708,16 +709,22 @@ clear_public_followup_link() { state=$STATE ;; secondmate:*) + 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:}") || rc=$? - [ "$rc" -ne 3 ] || return 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 - work_home_path=$(fm_pf_registry_get "$STATE" "$id" work_home_path) - case "$work_home_path" in /*) ;; *) return 1 ;; esac [ ! -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 ;; diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 6f17f07e29..0c59b90ad6 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -269,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" @@ -562,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" @@ -1756,6 +1756,44 @@ test_retire_refuses_unbound_existing_secondmate() { 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) @@ -2138,6 +2176,7 @@ 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 From 09e9e90ffe4d8ccef813aa5120b6d4701f905a25 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:52:26 -0700 Subject: [PATCH 15/18] no-mistakes(review): Fix rechain command output and expiry validation --- bin/fm-public-followup.sh | 17 +++++++++------ tests/fm-public-followup.test.sh | 36 ++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 4ec0877e2a..7c01fd8dd7 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -358,7 +358,7 @@ cmd_brief() { deliverable_flags= while IFS= read -r key; do [ -n "$key" ] || continue - deliverable_flags="${deliverable_flags} --deliverable ${key}= \\\\ + deliverable_flags="${deliverable_flags} --deliverable ${key}= \\ " done <' +${deliverable_flags} --outcome-text '' Do not post anything publicly yourself and do not look for the public thread: the home above owns the reply. @@ -1103,9 +1102,15 @@ cmd_rechain() { 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") - if [ "$window" = expired ]; then - 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 - fi + 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 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 0c59b90ad6..6643dbee1e 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1435,7 +1435,7 @@ test_control_registered_followon_is_guarded() { } test_rechain_delivers_second_post_on_same_thread() { - local parent log out posts + 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 @@ -1454,6 +1454,26 @@ test_rechain_delivers_second_post_on_same_thread() { "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" \ @@ -1987,7 +2007,7 @@ test_retention_creates_no_false_teardown_refusal() { } test_expiry_escalation_uses_now_override() { - local home out exp now_closing now_expired + 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) \ @@ -2016,6 +2036,18 @@ test_expiry_escalation_uses_now_override() { 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" } From f40bfa7cd58e4909b9ae1d21cb2127aa15d0ccfc Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 17:57:49 -0700 Subject: [PATCH 16/18] no-mistakes(review): Validate brief keys and warn on remote promotion --- bin/fm-promote.sh | 8 +++++- bin/fm-public-followup.sh | 5 ++-- tests/fm-public-followup.test.sh | 43 +++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index f3cf0a6633..7e844f942e 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -209,7 +209,13 @@ if [ -f "$FM_HOME/.fm-secondmate-home" ]; then 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 \ diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh index 7c01fd8dd7..dda567cca1 100755 --- a/bin/fm-public-followup.sh +++ b/bin/fm-public-followup.sh @@ -352,8 +352,9 @@ cmd_brief() { || 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) - | .[] | select(type == "string" and length > 0)' 2>/dev/null) \ + | 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 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 6643dbee1e..805466cdea 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -2052,22 +2052,41 @@ test_expiry_escalation_uses_now_override() { } test_brief_fails_without_typed_deliverable_keys() { - local home real_tasks + 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 -exit 69 +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" expect_failure \ + 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" } @@ -2121,7 +2140,7 @@ test_x_request_teardown_warns_when_final_unposted() { } test_secondmate_promotion_uses_teardown_parent_resolution() { - local parent stale child out + 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) @@ -2163,6 +2182,22 @@ test_secondmate_promotion_uses_teardown_parent_resolution() { "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" } From 9e24e26d53f2923c4446b3bd3cc406953c266d38 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 18:06:09 -0700 Subject: [PATCH 17/18] no-mistakes(document): Document retained public follow-up loops --- .agents/skills/fmx-respond/SKILL.md | 1 + AGENTS.md | 2 +- bin/fm-public-followup-lib.sh | 20 +++++++++++--------- docs/architecture.md | 2 +- docs/configuration.md | 9 +++++---- docs/scripts.md | 4 ++-- docs/verification/public-followup.md | 9 ++++++++- 7 files changed, 29 insertions(+), 18 deletions(-) diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 1a454c3fa3..d2aac94fb2 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -262,6 +262,7 @@ So treat second-mate-routed Relay work as a promised final by construction: the - "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. diff --git a/AGENTS.md b/AGENTS.md index f798d581fb..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 diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh index 3903c01d01..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. @@ -31,12 +32,13 @@ # Private transport layout, all under /state/public-followup (mode 0700, # initialized by `fm-public-followup.sh register` and extended only by these # public-followup commands): -# registry/ registration record: the bounded public-safe -# binding (obligation, relation, work ref, -# 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 +# 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. diff --git a/docs/architecture.md b/docs/architecture.md index 8a84e7a88b..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, the idempotent delivery sequence, and explicit loop closure: delivering a final stamps the registration delivered and leaves the public loop open until `retire`. +`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 f477a97311..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 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 private reason-and-time receipt written before removal, 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. @@ -502,7 +503,7 @@ Unreconciled terminal results ride the existing 30-second relay poll rather than 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 fc8f436131..373bee3596 100644 --- a/docs/verification/public-followup.md +++ b/docs/verification/public-followup.md @@ -60,8 +60,14 @@ ok - CONTROL: the identical teardown REFUSES the moment a commitment is register 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 @@ -109,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 owed-reply refusal plus the non-blocking open-loop and legacy `x_request=` warnings, which run before any backend command and key only on the task id and registration records, so they behave 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. From 51bdbca57f88febe50c001a20dc848357a28da3b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 21 Aug 2026 18:08:08 -0700 Subject: [PATCH 18/18] no-mistakes(lint): Remove unused bounded-wait loop variable --- tests/fm-public-followup.test.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 805466cdea..69a054abee 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -1849,7 +1849,7 @@ test_rechain_refuses_unclaimed_existing_destination() { } test_pending_skips_concurrent_retirement() { - local home log real_tasks pending_pid locker_pid rc=0 i + 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 @@ -1878,7 +1878,7 @@ test_pending_skips_concurrent_retirement() { fm_pf_registry_lock_release "$FM_RACE_HOME/state" pf-race ' & locker_pid=$! - for i in $(seq 1 100); do [ -e "$home/lock-ready" ] && break; sleep 0.02; done + 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) @@ -1895,7 +1895,7 @@ SH REAL_TASKS_AXI="$real_tasks" PENDING_LISTED="$home/pending-listed" \ run_pf "$home" pending > "$home/pending-race.out" 2>&1 & pending_pid=$! - for i in $(seq 1 100); do [ -e "$home/pending-listed" ] && break; sleep 0.02; done + 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"