diff --git a/AGENTS.md b/AGENTS.md index 53c601d9581..785a73ecd9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,9 +95,9 @@ state/ volatile runtime signals; gitignored .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) - x-context/ generated X-mode durable per-request reply context (platform/budget), keyed by request_id; survives inbox cleanup so a delayed follow-up recovers the original platform (section 14; bin/fm-x-lib.sh) + x-context/ generated X-mode 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 X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) - x-poll.error generated X-mode relay diagnostic dedupe marker + x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks diff --git a/bin/fm-x-lib.sh b/bin/fm-x-lib.sh index 0c7e8315258..a8ea57991cd 100644 --- a/bin/fm-x-lib.sh +++ b/bin/fm-x-lib.sh @@ -22,6 +22,8 @@ # fmx_context_registry_set [refresh] # - persist the durable per-request reply context; # refresh=1 resets its retention timestamp +# fmx_offer_registry_claim - atomically claim the durable +# one-wake offer marker; 0=new, 1=existing, 2=error # fmx_context_registry_prune - remove records older than seven days # fmx_context_registry_get - read the durable per-request # reply context, or the empty shape when absent @@ -176,6 +178,44 @@ fmx_private_artifact_publish_stdin() { fi } +# Publish stdin as a new private artifact without replacing an existing path. +# The hard-link claim is atomic within the prepared directory, so concurrent +# callers cannot both create the destination. Returns 0 when this caller created +# it, 1 when another valid private artifact already owns the path, and 2 on an +# unsafe path or publication failure. +fmx_private_artifact_publish_stdin_once() { + local dir=$1 base=$2 mode=$3 device tmp dest + case "$base" in + ''|.*|*/*) return 2 ;; + esac + case "$mode" in + 600|700) ;; + *) return 2 ;; + esac + device=$(fmx_private_artifact_dir_prepare "$dir") || return 2 + dest="$dir/$base" + tmp=$(umask 077; mktemp "$dir/.${base}.fm-x.XXXXXX" 2>/dev/null) || return 2 + if ! cat > "$tmp" \ + || ! chmod "$mode" "$tmp" 2>/dev/null \ + || ! fmx_single_link_file_mode_valid "$tmp" "$mode" "$device"; then + rm -f -- "$tmp" + return 2 + fi + if ln -- "$tmp" "$dest" 2>/dev/null; then + rm -f -- "$tmp" + if fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then + return 0 + fi + rm -f -- "$dest" + return 2 + fi + rm -f -- "$tmp" + if fmx_single_link_file_mode_valid "$dest" "$mode" "$device"; then + return 1 + fi + return 2 +} + fmx_private_artifact_file_valid() { local dir=$1 base=$2 mode=$3 device case "$base" in @@ -494,6 +534,32 @@ fmx_context_registry_set() { | fmx_private_artifact_publish_stdin "$dir" "$rid.json" 600) || return 1 } +# fmx_offer_registry_claim : atomically claim the durable +# one-wake marker at state/x-context/.offered.json. The marker uses +# the context registry's recorded_at retention contract, so its first claim +# survives inbox cleanup and expires with the relay's bounded follow-up window. +# Returns 0 only to the caller that created the marker, 1 when a valid marker +# already exists, and 2 on invalid input or a publication failure. +fmx_offer_registry_claim() { + local state=$1 rid=$2 dir now record rc + case "$rid" in + ''|.*|*[!A-Za-z0-9._-]*) return 2 ;; + esac + fmx_context_registry_prune "$state" + now=${FMX_NOW_OVERRIDE:-$(date +%s)} + case "$now" in + ''|*[!0-9]*) return 2 ;; + esac + [ "${#now}" -le 18 ] || return 2 + record=$(jq -cn --arg rid "$rid" --argjson recorded_at "$now" \ + '{request_id:$rid, recorded_at:$recorded_at}') || return 2 + dir="$state/x-context" + printf '%s\n' "$record" \ + | fmx_private_artifact_publish_stdin_once "$dir" "$rid.offered.json" 600 + rc=$? + return "$rc" +} + # fmx_context_registry_get : print the durable per-request # reply context as {"platform":"...","reply_max_chars":"..."} (the same shape as # the inbox and relay extractors), or the empty shape when no record exists. diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index a4382911005..db7c1ebba01 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -11,11 +11,12 @@ # Behavior when X mode is on: # HTTP 204 / empty / missing text -> print nothing, exit 0 (no wake) # auth/config errors -> print one rate-limited diagnostic -# a mention JSON with non-empty text -> stash the full object to +# a newly offered mention with non-empty text -> stash the full object to # state/x-inbox/.json, record the durable per-request reply -# context to state/x-context/.json (best-effort; see -# fm-x-lib.sh), and print one compact line "x-mention " (which -# becomes the watcher's check: wake payload) +# context to state/x-context/.json (best-effort), atomically +# claim state/x-context/.offered.json, and print one compact +# line "x-mention " (which becomes the watcher wake payload) +# an already offered request_id -> print nothing, exit 0 # The full object is stashed verbatim, so any conversation context the relay # includes (in_reply_to: {author_handle, text}, null for a fresh mention) is # preserved for fmx-respond to handle follow-ups with continuity. The durable @@ -39,6 +40,7 @@ fmx_load_config [ -n "$FMX_TOKEN" ] || exit 0 ERROR_FILE="$STATE/x-poll.error" +CLAIM_ERROR_FILE="$STATE/x-poll.claim-error" emit_error_once() { local msg=$1 @@ -56,6 +58,22 @@ clear_error() { rm -f "$ERROR_FILE" 2>/dev/null || true } +emit_claim_error_once() { + local msg=$1 + if fmx_private_artifact_file_valid "$STATE" "x-poll.claim-error" 600 \ + && [ "$(cat "$CLAIM_ERROR_FILE" 2>/dev/null)" = "$msg" ]; then + return 0 + fi + printf '%s\n' "$msg" \ + | fmx_private_artifact_publish_stdin "$STATE" "x-poll.claim-error" 600 2>/dev/null || true + printf 'x-mode-error %s\n' "$msg" +} + +clear_claim_error() { + fmx_private_artifact_dir_device "$STATE" >/dev/null 2>&1 || return 0 + rm -f "$CLAIM_ERROR_FILE" 2>/dev/null || true +} + command -v curl >/dev/null 2>&1 || { emit_error_once "missing curl"; exit 0; } command -v jq >/dev/null 2>&1 || { emit_error_once "missing jq"; exit 0; } @@ -100,6 +118,16 @@ case "$REQ" in ''|.*|*[!A-Za-z0-9._-]*) clear_error; exit 0 ;; esac +# The offer marker outlives the inbox file, which fmx-respond removes after a +# successful answer or dismiss. Checking it before the inbox stash keeps both a +# still-pending request and the relay's brief post-answer re-offer silent without +# recreating a drained inbox. The startup prune above bounds marker retention. +if fmx_private_artifact_file_valid "$STATE/x-context" "$REQ.offered.json" 600; then + clear_error + clear_claim_error + exit 0 +fi + INBOX="$STATE/x-inbox" # Stash the full mention object atomically so a concurrent reader never sees a # half-written file. @@ -123,5 +151,10 @@ if [ -n "$POLL_CTX" ]; then fmx_context_registry_set "$STATE" "$REQ" "$POLL_PLATFORM" "$POLL_MAX" 2>/dev/null || true fi -clear_error -printf 'x-mention %s\n' "$REQ" +fmx_offer_registry_claim "$STATE" "$REQ" +offer_rc=$? +case "$offer_rc" in + 0) clear_error; clear_claim_error; printf 'x-mention %s\n' "$REQ" ;; + 1) clear_error; clear_claim_error; exit 0 ;; + *) emit_claim_error_once "cannot record mention offer"; exit 0 ;; +esac diff --git a/docs/architecture.md b/docs/architecture.md index 4c4b1cac978..23ff9de20c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -190,7 +190,8 @@ Destructive, irreversible, or security-sensitive asks are escalated for trusted- The relay uses owner-only routing: a mention delivered to a home is from that home's owner, while parent-thread context may still include other public accounts. On the locked session-start bootstrap step, that token creates the local polling and watcher-cadence artifacts described in the [X mode configuration reference](configuration.md#x-mode-env). Without the token, the locked session-start bootstrap step removes those artifacts on opt-out and otherwise stays silent, so non-X users see no behavior change. -Pending mentions are stored as `state/x-inbox/.json`; the `fmx-respond` agent-only skill drains that inbox, uses `in_reply_to` parent-post context for conversational continuity, classifies each mention as an actionable request, question, or pure acknowledgment, and submits public-safe replies through `bin/fm-x-reply.sh`. +Newly offered mentions are stored as `state/x-inbox/.json` and wake firstmate once per retained request ID; the [X mode configuration reference](configuration.md#x-mode-env) owns the durable offer-marker and re-offer contract. +The `fmx-respond` agent-only skill drains that inbox, uses `in_reply_to` parent-post context for conversational continuity, classifies each mention as an actionable request, question, or pure acknowledgment, and submits public-safe replies through `bin/fm-x-reply.sh`. When a reply has a real visual artifact, `--image ` attaches one local PNG, JPEG, GIF, WebP, BMP, or TIFF to the relay's optional `{media_type,data_base64}` image object. Actionable reversible requests run through firstmate's normal intake, backlog, dispatch, investigation, or ship lifecycle. Work that completes in the answering turn gets one outcome reply. diff --git a/docs/configuration.md b/docs/configuration.md index 25560837d0d..bbe5ca4f355 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -288,7 +288,9 @@ Its request handling remains in X-specific `bin/` scripts and the `fmx-respond` `bin/fm-x-poll.sh` calls `GET /connector/poll` with `Authorization: Bearer `. HTTP 204 is silent. -A pending mention with non-empty `text` is stored at `state/x-inbox/.json` and wakes firstmate with `x-mention `. +A newly offered pending mention with non-empty `text` is stored at `state/x-inbox/.json` and wakes firstmate exactly once with `x-mention `. +The poll atomically claims `state/x-context/.offered.json` before emitting that wake, and subsequent offers of the same request stay silent even after the inbox is drained following an answer or dismiss. +Offer markers share the context registry's bounded seven-day retention, so losing or expiring the local marker lets a relay offer wake firstmate again. The full relay object is preserved, including `in_reply_to: {author_handle, text}` when the mention is a reply in a conversation or `null` for fresh mentions. At the same time the poll records a durable per-request reply context at `state/x-context/.json` (`{request_id, platform, reply_max_chars, recorded_at}`) from the same authoritative relay payload, best-effort and keyed by `request_id` so concurrent requests never overwrite each other; it survives the inbox cleanup that follows the acknowledgement, so a delayed follow-up can recover the original platform and split budget even with no task link. `recorded_at` begins as the locally observed first-seen Unix epoch and remains unchanged when the same request is polled again. @@ -307,8 +309,9 @@ This is what keeps a delayed request-id follow-up on the original platform's bud In that case the link is still recorded but `bin/fm-x-link.sh` prints a loud warning; and when either a follow-up's platform or explicit budget cannot be authoritatively resolved from any source, `bin/fm-x-reply.sh` refuses it (fail-safe exit 8) rather than posting with a local default - firstmate holds and retries it once both values are recoverable. Fresh links start with `x_followups=0` and the current timestamp; when relinking the same relay request onto a successor task, pass paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor preserves the already-consumed follow-up count, original 7-day window, and reply split budget. Pure acknowledgments or mentions with nothing to answer are dismissed through `bin/fm-x-dismiss.sh` before the local inbox file is cleared. -Dismiss sends `POST /connector/dismiss` with `{request_id}`, posts no text, and tells the relay to drop the request instead of re-offering it or falling back to an offline auto-reply; on success it also clears that request's durable per-request context, since a dismissed mention never gets a follow-up. +Dismiss sends `POST /connector/dismiss` with `{request_id}`, posts no text, and tells the relay to drop the request instead of re-offering it or falling back to an offline auto-reply; on success it clears that request's durable reply-context record, while the separate offer marker remains for its bounded retention so a brief relay re-offer stays silent. Relay auth or config problems are reported once as `x-mode-error ...` until recovery. +A failed durable offer claim is likewise reported once as `x-mode-error cannot record mention offer` and remains deduplicated through quiet no-pending polls until a later offer confirms an existing valid marker or claims a new one. Live replies are posted by `bin/fm-x-reply.sh`, which sends `POST /connector/answer` with `{request_id,text}` for one-message replies. Add `--image ` to attach one local PNG, JPEG, GIF, WebP, BMP, or TIFF as `{media_type,data_base64}` in the relay's optional `image` object. Completion follow-ups use `bin/fm-x-followup.sh`, which checks the local `state/.meta` link and sends the same payload shape through `POST /connector/followup` by calling `bin/fm-x-reply.sh --followup`, up to three times per link within the window. diff --git a/docs/scripts.md b/docs/scripts.md index 8a5bb5a9c13..bc16c6d2c99 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -79,7 +79,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | -| `fm-x-poll.sh` | One bounded X relay poll: stash pending mentions, print `x-mention ` | +| `fm-x-poll.sh` | One bounded X relay poll: stash newly offered mentions and emit their once-only wake | | `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up | | `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta | diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index da2800034c4..a79f2869ad5 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -289,6 +289,108 @@ test_poll_question_stashes_and_marks() { pass "fm-x-poll stashes the question and prints the compact marker" } +test_poll_mentions_wake_once_per_durable_offer() { + local home fakebin out rc body marker + home="$TMP_ROOT/poll-offer-dedupe"; mkdir -p "$home" + fakebin=$(make_fake_curl "$home") + printf 'FMX_PAIRING_TOKEN=tok-offer\n' > "$home/.env" + body='{"request_id":"req-repeat","platform":"discord","reply_max_chars":1900,"text":"status?"}' + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000000 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "first offered mention poll exit" + [ "$out" = "x-mention req-repeat" ] \ + || fail "a newly offered mention must wake once (got: $out)" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000030 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "repeated pending mention poll exit" + [ -z "$out" ] || fail "an already offered pending mention must stay silent (got: $out)" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FAKE_DISMISS_CODE=200 "$ROOT/bin/fm-x-dismiss.sh" req-repeat); rc=$? + expect_code 0 "$rc" "successful dismiss before relay re-offer exit" + [ "$out" = "req-repeat" ] || fail "the dismiss fixture must succeed before the re-offer" + rm -f "$home/state/x-inbox/req-repeat.json" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000060 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "post-answer re-offer poll exit" + [ -z "$out" ] || fail "a relay re-offer after inbox cleanup must stay silent (got: $out)" + assert_absent "$home/state/x-inbox/req-repeat.json" \ + "a suppressed post-answer re-offer must not recreate the drained inbox" + marker="$home/state/x-context/req-repeat.offered.json" + assert_present "$marker" "the durable offer marker must survive inbox cleanup" + rm -f "$marker" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000090 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "mention re-offer after local marker loss exit" + [ "$out" = "x-mention req-repeat" ] \ + || fail "a re-offer after local marker loss must wake once (got: $out)" + body='{"request_id":"req-new","platform":"discord","reply_max_chars":1900,"text":"new status?"}' + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700000120 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "genuinely new mention poll exit" + [ "$out" = "x-mention req-new" ] \ + || fail "a genuinely new request_id must wake once (got: $out)" + marker="$home/state/x-context/req-new.offered.json" + [ "$(path_mode "$marker")" = 600 ] \ + || fail "the durable offer marker must be a private file" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_NOW_OVERRIDE=1700604921 \ + FMX_RELAY_URL="https://relay.test" FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "mention re-offer after marker expiry exit" + [ "$out" = "x-mention req-new" ] \ + || fail "a re-offer after the bounded marker expiry must wake once (got: $out)" + pass "fm-x-poll wakes once per durable request offer across inbox cleanup" +} + +test_poll_offer_claim_failure_reports_once() { + local home fakebin out rc body + home="$TMP_ROOT/poll-offer-claim-failure"; mkdir -p "$home/state" "$home/external-context" + fakebin=$(make_fake_curl "$home") + chmod 700 "$home/state" + ln -s "$home/external-context" "$home/state/x-context" + body='{"request_id":"req-claim-failure","text":"status?"}' + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "first offer claim failure poll exit" + [ "$out" = "x-mode-error cannot record mention offer" ] \ + || fail "an offer claim failure must emit one diagnostic (got: $out)" + assert_present "$home/state/x-poll.claim-error" "offer claim failure must write a dedupe marker" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "repeated offer claim failure poll exit" + [ -z "$out" ] || fail "a repeated offer claim failure must stay silent (got: $out)" + assert_present "$home/state/x-poll.claim-error" "a repeated offer claim failure must retain its dedupe marker" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=204 \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "no-pending poll after offer claim failure exit" + [ -z "$out" ] || fail "a no-pending poll must stay silent after an offer claim failure (got: $out)" + assert_present "$home/state/x-poll.claim-error" \ + "a no-pending poll must retain the offer claim dedupe marker" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "re-offered claim failure poll exit" + [ -z "$out" ] || fail "a re-offered claim failure must stay silent (got: $out)" + rm "$home/state/x-context" + mkdir "$home/state/x-context" + chmod 700 "$home/state/x-context" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FMX_RELAY_URL="https://relay.test" \ + FMX_PAIRING_TOKEN=tok-claim-failure FAKE_POLL_CODE=200 FAKE_POLL_BODY="$body" \ + "$ROOT/bin/fm-x-poll.sh"); rc=$? + expect_code 0 "$rc" "recovered offer claim poll exit" + [ "$out" = "x-mention req-claim-failure" ] \ + || fail "a recovered offer claim must emit the mention wake (got: $out)" + assert_absent "$home/state/x-poll.claim-error" "a successful offer claim must clear the diagnostic marker" + pass "fm-x-poll retains offer claim diagnostics until recovery" +} + test_poll_preserves_conversation_context() { local home fakebin out rc body f home="$TMP_ROOT/poll-ctx"; mkdir -p "$home" @@ -2671,6 +2773,8 @@ test_poll_empty_env_relay_overrides_env_file test_poll_auth_error_reports_once test_poll_error_private_publication_rejects_unsafe_paths test_poll_question_stashes_and_marks +test_poll_mentions_wake_once_per_durable_offer +test_poll_offer_claim_failure_reports_once test_poll_preserves_conversation_context test_poll_inbox_commit_failure_reports_error test_poll_inbox_private_publication_rejects_unsafe_paths