Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions bin/fm-pending-reply-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# to a secondmate, this library records a durable parent-owned pending-reply
# expectation BEFORE delivery, embeds a privacy-safe correlation id in the
# outbound message, and later resolves that expectation only from a correlated
# parent status line or status-pointed document - never from transport success,
# chat content, or unrelated status activity.
# line in the asked task's own parent status log, or a document it points to -
# never from transport success, chat content, unrelated status activity, or
# another task's line that echoes or quotes the token.
#
# Safety property (captain direction 2026-07-22): a secondmate agent may ignore
# the marker and answer only in its visible conversation. The parent must notice
Expand Down Expand Up @@ -194,14 +195,12 @@ fm_pending_reply_extract_corr() { # <text>
printf '%s' "$text" | grep -oE "$FM_PENDING_REPLY_CORR_RE" 2>/dev/null | head -1 | cut -d= -f2- | tr 'A-F' 'a-f' || true
}

# 0 if <text> carries the exact correlation token for <corr_id>.
# 0 if <text> carries the exact correlation token for <corr_id>, as a whole
# word: xcorr=<id> or corr=<id>ff is a different token, not this one.
fm_pending_reply_text_has_corr() { # <text> <corr_id>
local text=$1 corr=$2 token
token=$(fm_pending_reply_corr_token "$corr")
case "$text" in
*"$token"*) return 0 ;;
esac
return 1
local text=$1 corr=$2 re
re="(^|[^[:alnum:]_])$(fm_pending_reply_corr_token "$corr")([^[:alnum:]_]|\$)"
[[ $text =~ $re ]]
}

# Sanitize a short request summary: single line, bounded, no control chars.
Expand Down Expand Up @@ -689,7 +688,13 @@ _fm_pending_reply_try_resolve_locked() { # <state-dir> <corr_id> [status-file-o
case "$delivery_state" in attempted|confirmed) ;; *) return 1 ;; esac
unconfirmed=1
fi
status_file=${status_override:-$(fm_pending_reply_get "$rec" parent_status)}
status_file=$(fm_pending_reply_get "$rec" parent_status)
# Only the asked task's own status log answers its request: another mate's
# line echoing or quoting this corr= token must leave the request open.
if [ -n "$status_override" ]; then
[ "$status_override" -ef "$status_file" ] || return 1
status_file=$status_override
fi
if [ -z "$status_override" ] && [ "$unconfirmed" = 0 ]; then
signature=$(fm_pending_reply_file_signature "$status_file")
previous=$(fm_pending_reply_get "$rec" parent_status_scan_signature)
Expand Down
4 changes: 3 additions & 1 deletion docs/remote-secondmates.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,9 @@ An SSH exit status of 255 while fetching a referenced document leaves the delta

The process-event runner applies each captured delta through this adapter as soon as it is captured.
So a mirrored reply reaches the primary status channel without depending on the wake handler running the adapter itself.
A mirrored line that carries a correlation token settles its pending-reply record and closes that request's own open escalation decision.
A mirrored line that carries a correlation token settles the pending-reply record only when the mate it came from is the task that request was sent to.
Another mate echoing the token leaves it open.
A settled record also closes that request's own open escalation decision.

A remote reply reaches the primary only through this asynchronous mirror.
Because of that, the primary treats a missing correlated report as a missed report only once the mirror has been read through the end of the remote log after that turn ended.
Expand Down
34 changes: 34 additions & 0 deletions tests/fm-pending-reply.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
# 4. Second missed turn escalates once and remains durable
# 5. Transport success cannot masquerade as reply success
# 6. Unrelated events and stale correlation ids cannot resolve a request
# - including another mate's line that echoes the request's token, or a
# token embedded in a longer word
# 7. Restart/compaction preserves the expectation and exact parent destination
# 8. Wrong-home reports are detected but do not silently acknowledge
# 9. Direct unmarked captain input creates no expectation
Expand Down Expand Up @@ -1067,6 +1069,37 @@ test_unrelated_and_stale_corr_cannot_resolve() {
pass "unrelated events and stale correlation ids cannot resolve"
}

test_another_mates_echo_cannot_resolve() {
local home state corr_a corr_b
home=$(setup_parent two-mates)
state="$home/state"
# shellcheck disable=SC2031
export FM_PENDING_REPLY_NOW=6100
corr_a=$(fm_pending_reply_create "$home" "$state" "alpha" "need alpha's answer")
corr_b=$(fm_pending_reply_create "$home" "$state" "beta" "need beta's answer")
fm_pending_reply_mark_delivered "$state" "$corr_a"
fm_pending_reply_mark_delivered "$state" "$corr_b"
# Beta's reply echoes alpha's token; remote reply ingest hands every corr= in
# beta's payload over together with beta's own status log.
printf 'done [corr=%s]: answered, and alpha still owes corr=%s\n' "$corr_b" "$corr_a" \
> "$state/beta.status"
if fm_pending_reply_try_resolve "$state" "$corr_a" "$state/beta.status"; then
fail "another mate's line echoing the token must not resolve the request"
fi
[ "$(phase_of "$state" "$corr_a")" = awaiting_report ] || fail "alpha's request must stay open"
fm_pending_reply_try_resolve "$state" "$corr_b" "$state/beta.status" \
|| fail "beta's own correlated line should resolve beta's request"
printf 'done corr=%sff: a longer token is a different token\n' "$corr_a" > "$state/alpha.status"
printf 'done xcorr=%s: so is a prefixed one\n' "$corr_a" >> "$state/alpha.status"
if fm_pending_reply_try_resolve "$state" "$corr_a"; then
fail "a token embedded in a longer word must not resolve"
fi
printf 'done [corr=%s]: alpha answered\n' "$corr_a" >> "$state/alpha.status"
fm_pending_reply_try_resolve "$state" "$corr_a" "$state/alpha.status" \
|| fail "alpha's own correlated line should resolve alpha's request"
pass "another mate's echoed token cannot resolve a request"
}

test_restart_preserves_expectation_and_parent_destination() {
local home state corr rec parent_status parent_home
home=$(setup_parent restart)
Expand Down Expand Up @@ -2093,6 +2126,7 @@ test_undelivered_records_are_scan_immutable
test_delivery_confirmation_fallback_reconciles
test_delivery_confirmation_serializes_with_reconciliation
test_unrelated_and_stale_corr_cannot_resolve
test_another_mates_echo_cannot_resolve
test_restart_preserves_expectation_and_parent_destination
test_wrong_home_detected_not_acknowledged
test_unmarked_captain_input_creates_no_expectation
Expand Down
Loading