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
9 changes: 5 additions & 4 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,12 @@ status_is_paused_or_captain_held() { # <status-line>
# the historical one-open-decision-per-task behavior (a bare "resolved:" closes
# "default"). A stated key whose slug fails the charset below is rejected (the
# folds skip the line), never rewritten to "default".
# The parsers are pure reads of a single line; the verb parser strips any key
# token before the colon so the leading word is recovered cleanly.
# The parsers are pure reads of a single line. Status metadata may contain any
# number of "[name=value]" tags before the colon, in any order, so verb parsing
# ends at the first tag rather than special-casing "[key=...]".
status_line_verb() { # <status-line> -> leading verb word
local v=${1%%:*}
v=${v%%\[key=*}
v=${v%%\[*}
v=${v#"${v%%[![:space:]]*}"}
v=${v%"${v##*[![:space:]]}"}
printf '%s' "$v"
Expand Down Expand Up @@ -436,7 +437,7 @@ _fm_open_decisions_cursor_path() { # <status-file>
printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}"
}

FM_OPEN_DECISIONS_FOLD_VERSION=3
FM_OPEN_DECISIONS_FOLD_VERSION=4

# Portable device:inode identity for the rotation/recreation check below.
_fm_open_decisions_file_ident() { # <file> -> "dev:inode", empty on I/O failure
Expand Down
97 changes: 95 additions & 2 deletions tests/fm-classify-decision-key.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
# documented between the verb and the colon (needs-decision [key=x]: note), but
# workers commonly write the colon first (needs-decision: [key=x] note); that
# stated key must be honored, never silently folded into the shared "default"
# bucket where an answer can close the wrong record (issue #2109). These tests
# drive the REAL status_open_decisions / status_open_decisions_incremental
# bucket where an answer can close the wrong record (issue #2109). Also covers
# status_line_verb's bracket-tag stripping: a remote secondmate reply prepends
# a "[corr=...]" correlation tag before (or without) "[key=...]", and every
# such tag before the colon must be stripped so the leading word is the bare
# verb, regardless of order or count. These tests drive the REAL
# status_line_verb / status_open_decisions / status_open_decisions_incremental
# functions over crafted status files and assert their folded output, never the
# fold's own source text. Cross-drain cursor persistence and the incremental
# cost bound live in tests/fm-wake-drain-open-decisions-cursor.test.sh; the
Expand Down Expand Up @@ -148,6 +152,90 @@ test_malformed_stated_key_never_collapses_to_default() {
pass "a malformed stated key is rejected in both positions, never folded as default"
}

# A remote secondmate reply routinely prepends a "[corr=<hex>]" correlation
# tag ahead of "[key=...]" (issue: a remote reply's "needs-decision
# [corr=d448ea86afa4bf67] [key=x]: ..." folded to no open decision at all,
# because the verb parser only stripped a leading "[key=...]" token and left
# the corr tag glued onto the returned verb word). These cases drive the real
# status_line_verb directly, over every bracket-tag shape that precedes the
# colon, to pin the general fix: strip EVERY "[name=value]" tag there, not
# just "[key=...]", regardless of order or count.
test_status_line_verb_strips_every_bracket_tag_before_colon() {
local v

v=$(status_line_verb 'needs-decision [corr=d448ea86afa4bf67] [key=loan-installment-cadence-amount]: fill in the terms')
[ "$v" = "needs-decision" ] || fail "corr-then-key tag order: got '$v'"

v=$(status_line_verb 'needs-decision [key=loan-installment-cadence-amount] [corr=d448ea86afa4bf67]: fill in the terms')
[ "$v" = "needs-decision" ] || fail "key-then-corr tag order: got '$v'"

v=$(status_line_verb 'needs-decision [corr=d448ea86afa4bf67]: fill in the terms')
[ "$v" = "needs-decision" ] || fail "corr-only tag: got '$v'"

v=$(status_line_verb 'blocked [corr=aaaa1111bbbb2222] [key=creds]: waiting on the deploy token')
[ "$v" = "blocked" ] || fail "blocked with corr+key: got '$v'"

v=$(status_line_verb 'resolved [corr=aaaa1111bbbb2222] [key=creds]: answered: rotated')
[ "$v" = "resolved" ] || fail "resolved with corr+key: got '$v'"

pass "status_line_verb strips every bracket tag before the colon, in any order, and recovers the bare verb"
}

test_corr_and_key_tags_open_and_close_under_the_stated_key() {
local dir expected
dir=$(case_dir corr-and-key)
printf 'needs-decision [corr=d448ea86afa4bf67] [key=loan-installment-cadence-amount]: pick the cadence\n' \
> "$dir/t.status"
expected=$(printf 'loan-installment-cadence-amount\tneeds-decision\tpick the cadence\n')
assert_fold "$dir/t.status" "$expected" "corr-then-key opens under the stated key"

printf 'resolved [corr=d448ea86afa4bf67] [key=loan-installment-cadence-amount]: answered: monthly\n' \
>> "$dir/t.status"
assert_fold "$dir/t.status" "" "corr-then-key resolution closes the same stated key"
pass "a [corr=...] tag ahead of [key=...] no longer swallows the verb: opens and closes under the stated key"
}

test_corr_only_tag_opens_as_default_like_a_bare_line() {
local dir bare corred
dir=$(case_dir corr-only)
printf 'needs-decision: which vendor\n' > "$dir/bare.status"
printf 'needs-decision [corr=d448ea86afa4bf67]: which vendor\n' > "$dir/corred.status"

bare=$(status_open_decisions "$dir/bare.status")
corred=$(status_open_decisions "$dir/corred.status")
[ "$corred" = "$bare" ] \
|| fail "a corr-only tag folded differently than the bare line: '$corred' vs '$bare'"
assert_fold "$dir/corred.status" "$(printf 'default\tneeds-decision\twhich vendor\n')" "corr-only tag"
pass "a [corr=...] tag with no stated key opens under 'default', exactly like a bare needs-decision line"
}

test_key_only_before_colon_still_opens_no_regression() {
local dir
dir=$(case_dir key-only-no-corr)
printf 'needs-decision [key=loan-installment-cadence-amount]: pick the cadence\n' > "$dir/t.status"
assert_fold "$dir/t.status" \
"$(printf 'loan-installment-cadence-amount\tneeds-decision\tpick the cadence\n')" \
"key-only before colon, no corr tag"
pass "a [key=x] tag alone (no corr tag) still opens x - no regression from the tag-stripping fix"
}

test_blocked_and_resolved_are_tag_order_independent() {
local dir
dir=$(case_dir blocked-tag-order)
printf 'blocked [corr=aaaa1111bbbb2222] [key=creds]: waiting on the deploy token\n' > "$dir/a.status"
assert_fold "$dir/a.status" "$(printf 'creds\tblocked\twaiting on the deploy token\n')" \
"blocked corr-then-key"

printf 'blocked [key=creds] [corr=aaaa1111bbbb2222]: waiting on the deploy token\n' > "$dir/b.status"
assert_fold "$dir/b.status" "$(printf 'creds\tblocked\twaiting on the deploy token\n')" \
"blocked key-then-corr"

printf 'blocked [corr=aaaa1111bbbb2222] [key=creds]: waiting on the deploy token\n' > "$dir/c.status"
printf 'resolved [corr=aaaa1111bbbb2222] [key=creds]: answered: rotated\n' >> "$dir/c.status"
assert_fold "$dir/c.status" "" "blocked/resolved corr+key close together regardless of tag order"
pass "blocked/resolved parse their bare verb with any bracket-tag order preceding the colon"
}

test_incremental_agrees_with_full_fold_across_appends() {
local dir f expected
dir=$(case_dir incremental)
Expand Down Expand Up @@ -178,4 +266,9 @@ test_blocked_is_position_tolerant_like_needs_decision
test_two_colon_form_decisions_stay_distinct
test_mid_note_prose_mention_is_not_a_stated_key
test_malformed_stated_key_never_collapses_to_default
test_status_line_verb_strips_every_bracket_tag_before_colon
test_corr_and_key_tags_open_and_close_under_the_stated_key
test_corr_only_tag_opens_as_default_like_a_bare_line
test_key_only_before_colon_still_opens_no_regression
test_blocked_and_resolved_are_tag_order_independent
test_incremental_agrees_with_full_fold_across_appends
37 changes: 37 additions & 0 deletions tests/fm-send-resolve-key.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,42 @@ test_remote_secondmate_answer_closes_locally() {
pass "fm-send --resolve-key: a remote-secondmate answer closes the same local ledger, transport-only difference"
}

# The reported failure: a remote secondmate reply line prepends a
# "[corr=<hex>]" correlation tag ahead of "[key=...]"
# (needs-decision [corr=d448ea86afa4bf67] [key=x]: ...). The verb parser used
# to strip only a leading "[key=...]" token, so the corr tag stayed glued onto
# the returned verb and the fold never recognized the line as a decision at
# all - "--resolve-key x" refused with "no open decision with that key" even
# though the key was right there on the line. This drives the real fm-send
# over that exact line shape and asserts the answer now succeeds and closes it.
test_remote_reply_corr_tag_does_not_block_resolve_key() {
local dir fb log home ssh_log rc out
dir="$TMP_ROOT/remote-corr-tag"; mkdir -p "$dir"
fb=$(make_stubs "$dir"); log="$dir/send.log"; ssh_log="$dir/ssh.log"; : > "$ssh_log"
home=$(setup_remote_home remote-corr-tag)
printf 'needs-decision [corr=d448ea86afa4bf67] [key=loan-installment-cadence-amount]: pick the cadence\n' \
> "$home/state/rsm.status"

out=$(drain_out "$home")
printf '%s' "$out" | grep -F '[key=loan-installment-cadence-amount]' >/dev/null \
|| fail "precondition: the corr-tagged remote decision should list as open under its stated key: $out"

: > "$log"
env PATH="$fb:$PATH" \
FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \
FM_SSH_BIN="$fb/fake-ssh" FM_SSH_LOG="$ssh_log" FM_FAKE_SSH_RC=0 \
"$SEND" rsm --resolve-key loan-installment-cadence-amount "monthly" >/dev/null 2>&1; rc=$?
expect_code 0 "$rc" "answering a corr-tagged remote decision should succeed, not refuse as unknown"
grep -F 'resolved [key=loan-installment-cadence-amount]: answered: monthly' "$home/state/rsm.status" >/dev/null \
|| fail "the closing resolved line is missing:"$'\n'"$(cat "$home/state/rsm.status")"

out=$(drain_out "$home")
if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then
fail "the answered corr-tagged remote decision still lists as open: $out"
fi
pass "fm-send --resolve-key: a remote reply's leading [corr=...] tag no longer blocks closing its stated key"
}

test_remote_transport_failure_does_not_close() {
local dir fb log home ssh_log rc out
dir="$TMP_ROOT/remote-fail"; mkdir -p "$dir"
Expand Down Expand Up @@ -433,5 +469,6 @@ test_failed_send_does_not_close
test_multiple_keys_close_together
test_local_secondmate_answer_marked_and_closed
test_remote_secondmate_answer_closes_locally
test_remote_reply_corr_tag_does_not_block_resolve_key
test_remote_transport_failure_does_not_close
test_flag_misuse_refuses
34 changes: 34 additions & 0 deletions tests/fm-wake-drain-open-decisions-cursor.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,39 @@ SH
pass "a cursor-cache read failure refolds the authoritative status file without hiding an open decision"
}

test_pre_fix_cursor_refolds_corr_tagged_decision() {
local dir state status cursor out probe status_bytes ident probe_bytes
dir=$(make_case cursor-corr-tag-migration)
state="$dir/state"
status="$state/task7.status"
cursor="$state/.task7.open-decisions-cursor"
out="$dir/drain.out"
probe="$dir/probe.tsv"

printf 'needs-decision [corr=d448ea86afa4bf67] [key=loan-installment-cadence-amount]: pick the cadence\n' > "$status"
FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \
|| fail "bootstrap drain for the corr-tag cursor migration failed"
ident=$(sed -n 's/^ident=//p' "$cursor")
[ -n "$ident" ] || fail "bootstrap drain did not persist a file identity"
status_bytes=$(LC_ALL=C wc -c < "$status" | tr -d '[:space:]')
{
printf 'version=3\n'
printf 'offset=%s\n' "$status_bytes"
printf 'ident=%s\n' "$ident"
} > "$cursor"
: > "$probe"

FM_STATE_OVERRIDE="$state" FM_OPEN_DECISIONS_READ_PROBE="$probe" "$DRAIN" > "$out" \
|| fail "drain failed while migrating the pre-fix corr-tag cursor"
grep -F 'task7 [key=loan-installment-cadence-amount] needs-decision: pick the cadence' "$out" >/dev/null \
|| fail "the pre-fix cursor hid the corr-tagged decision after migration: $(cat "$out")"
probe_bytes=$(last_probe_bytes "$probe" "$status")
[ "$probe_bytes" = "$status_bytes" ] \
|| fail "the pre-fix cursor read $probe_bytes bytes instead of refolding all $status_bytes authoritative bytes"

pass "a pre-fix cursor is rebuilt so a previously skipped corr-tagged decision surfaces"
}

test_previous_fold_cache_is_refolded_under_current_semantics() {
local dir state status cursor out probe status_bytes ident appended_bytes probe_bytes
dir=$(make_case cursor-fold-version)
Expand Down Expand Up @@ -315,5 +348,6 @@ test_truncated_log_falls_back_to_a_full_refold_not_a_dropped_decision
test_same_size_rewrite_is_detected_via_inode_identity
test_read_failure_never_silently_returns_empty
test_cursor_cache_read_failure_refolds_authoritative_status
test_pre_fix_cursor_refolds_corr_tagged_decision
test_previous_fold_cache_is_refolded_under_current_semantics
test_buried_decision_survives_many_growing_drains_and_resolution_clears_it
Loading