Skip to content
Closed
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
20 changes: 19 additions & 1 deletion bin/fm-contributions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
# to every owner without another forge read. When the budget runs out
# mid-observation, the poll ends with that URL's records untouched; only a
# genuine forge failure or head change records an error.
# A five-second read timeout outside the budget's own deadline leaves a fresh
# prior observation untouched and retries next poll. Once that observation
# exceeds FM_CONTRIBUTIONS_MAX_AGE (or none exists), the timeout records an
# unavailable error and wakes once per failure episode.
# API failure leaves error evidence; an expired or absent observation is not
# silence. FM_CONTRIBUTIONS_MAX_AGE (default 900 seconds) bounds freshness.
# A URL whose last good observation is merged or closed is final: it is
Expand Down Expand Up @@ -193,6 +197,8 @@ forge() {
if [ "$rc" -eq 124 ] && [ "$bounded" -eq 1 ]; then
BUDGET_EXHAUSTED=1
: > "$TMP/budget-exhausted"
elif [ "$rc" -eq 124 ]; then
: > "$TMP/forge-timeout"
elif [ "$rc" -ne 0 ]; then
: > "$TMP/forge-unavailable"
fi
Expand All @@ -215,7 +221,7 @@ observe() { # canonical GitHub URL -> normalized JSON
case "$url" in https://github.com/*) ;; *) return 1 ;; esac
part=${url#https://github.com/}; number=${part##*/}; part=${part%/*}; kind=${part##*/}; part=${part%/*}
case "$kind" in pull) endpoint="repos/$part/pulls/$number" ;; issues) endpoint="repos/$part/issues/$number" ;; *) return 1 ;; esac
rm -f -- "$TMP/budget-exhausted" "$TMP/forge-unavailable"
rm -f -- "$TMP/budget-exhausted" "$TMP/forge-unavailable" "$TMP/forge-timeout"
forge api "$endpoint" > "$TMP/core.json" || return 1
jq -e '(.state == "open" or .state == "closed") and (.user.login | type == "string")' "$TMP/core.json" >/dev/null || return 1
if [ "$kind" = pull ]; then
Expand Down Expand Up @@ -356,6 +362,18 @@ poll() {
# An observation the budget cut short is unmeasured, not unavailable: keep
# every owner's prior record so the URL is observed first next poll.
[ "$BUDGET_EXHAUSTED" -eq 0 ] || break
# A single read timeout does not invalidate an otherwise fresh URL.
# Preserve its last good evidence and retry next poll; a missing or stale
# observation still takes the normal unavailable path below.
if [ "$observed" -ne 0 ] && [ -e "$TMP/forge-timeout" ] && [ ! -e "$TMP/forge-unavailable" ] \
&& jq -ne --slurpfile saved "$TMP/saved.json" --arg url "$url" \
--argjson now "$EPOCH" --argjson max_age "$MAX_AGE" '
any($saved[0][] | .records[] | select(.url == $url);
.error == null and .observation != null and .checked_at != null
and ($now - (.checked_at | fromdateiso8601)) >= 0
and ($now - (.checked_at | fromdateiso8601)) <= $max_age)' >/dev/null; then
continue
fi
# Wake once per failure episode: only when no owner has a prior error.
if [ "$observed" -ne 0 ] && jq -ne --slurpfile saved "$TMP/saved.json" --arg url "$url" --args \
'all($ARGS.positional[] as $task | [$saved[0][] | select(.task == $task) | .records[] | select(.url == $url)] | first;
Expand Down
49 changes: 48 additions & 1 deletion tests/fm-contributions.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ case "$fault:$*" in
fail:'api repos/o/r/pulls/8/reviews?'*) printf 'HTTP 502\n' >&2; exit 1 ;;
down:*) printf 'HTTP 502\n' >&2; exit 1 ;;
hang:'api repos/o/r/pulls/8') sleep 4 ;;
timeout-events:'api repos/o/r/issues/9/events?'*) sleep 6 ;;
head:'pr view '*) printf '{"headRefOid":"%s","reviewDecision":"APPROVED"}\n' "$(printf 'b%.0s' $(seq 40))"; exit 0 ;;
esac
exec "$(dirname "$0")/gh-fixture" "$@"
Expand Down Expand Up @@ -619,6 +620,52 @@ test_genuine_failure_near_deadline_is_unavailable() {
pass 'a genuine forge failure inside the budget still records the error and wakes'
}

test_issue_read_timeout_between_successes_stays_silent() {
local home out
home=$(new_home issue-timeout-recovery)
forge_home "$home"
wrap_forge "$home"
mutate_record "$home" delivery '.records[0].observation.state="merged"'
printf -- '- [ ] filed - Measured defect https://github.com/o/r/issues/9 (repo: sample) (kind: ship)\n' >> "$home/data/backlog.md"
with_home "$home" "$ROOT/bin/fm-contributions.sh" poll >/dev/null || fail 'initial issue observation failed'
cp "$home/data/filed/contributions.json" "$home/prior.json"
printf 'timeout-events\n' > "$home/forge/fault"
out=$(with_home "$home" env FM_CONTRIBUTIONS_NOW=2026-09-16T08:01:00Z "$ROOT/bin/fm-contributions.sh" poll) \
|| fail 'poll failed on a transient issue-events timeout'
[ -z "$out" ] || fail "a transient issue-events timeout printed a wake: $out"
cmp -s "$home/prior.json" "$home/data/filed/contributions.json" \
|| fail 'a transient issue-events timeout changed the prior observation'
[ ! -s "$home/state/.wake-queue" ] || fail 'a transient issue-events timeout enqueued a wake'
: > "$home/forge/fault"
out=$(with_home "$home" env FM_CONTRIBUTIONS_NOW=2026-09-16T08:02:00Z "$ROOT/bin/fm-contributions.sh" poll) \
|| fail 'issue observation did not recover after a transient timeout'
[ -z "$out" ] || fail "issue recovery printed a wake: $out"
jq -e '.records[0] | .checked_at == "2026-09-16T08:02:00Z" and .error == null' \
"$home/data/filed/contributions.json" >/dev/null || fail 'issue recovery did not refresh the observation'
pass 'one issue read timeout between successes retains the prior observation without a wake'
}

test_persistent_issue_read_timeout_wakes_when_stale() {
local home out line='contributions: observation unavailable for https://github.com/o/r/issues/9'
home=$(new_home issue-timeout-stale)
forge_home "$home"
wrap_forge "$home"
mutate_record "$home" delivery '.records[0].observation.state="merged"'
printf -- '- [ ] filed - Measured defect https://github.com/o/r/issues/9 (repo: sample) (kind: ship)\n' >> "$home/data/backlog.md"
with_home "$home" "$ROOT/bin/fm-contributions.sh" poll >/dev/null || fail 'initial issue observation failed'
printf 'timeout-events\n' > "$home/forge/fault"
out=$(with_home "$home" env FM_CONTRIBUTIONS_NOW=2026-09-16T08:01:00Z "$ROOT/bin/fm-contributions.sh" poll) \
|| fail 'first timed-out issue poll failed'
[ -z "$out" ] || fail "a timeout within the freshness bound woke: $out"
out=$(with_home "$home" env FM_CONTRIBUTIONS_NOW=2026-09-16T08:16:00Z "$ROOT/bin/fm-contributions.sh" poll) \
|| fail 'stale timed-out issue poll failed'
[ "$out" = "$line" ] || fail "a persistent timeout did not wake after freshness expired: $out"
jq -e '.records[0] | .checked_at == "2026-09-16T08:16:00Z"
and .error == "forge observation unavailable or changed during read"' \
"$home/data/filed/contributions.json" >/dev/null || fail 'persistent timeout left no error evidence'
pass 'persistent issue timeouts wake after the last good observation expires'
}

test_shared_url_observed_once() {
local mode home out calls expected
for mode in ok fail head; do
Expand Down Expand Up @@ -829,7 +876,7 @@ test_late_owner_keeps_failure_episode_suppressed() {
}

failures=0
for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do
for test_name in test_actor_coverage test_stale_verdict test_unchecked_is_not_silence test_newest_check_has_no_verdict test_comment_wake test_review_wake test_inline_wake test_ready_issue_wake test_fresh_issue_requires_maintainer test_missing_lane_remains_missing test_partial_freshness_keeps_measured_rows test_malformed_record_cannot_prove_silence test_issue_timeline_and_exact_ack test_verdict_retains_judged_head test_observed_replacement_refreshes_verdict test_unobserved_head_leaves_verdict_unknown test_away_yolo_is_fleet_work test_away_yolo_cross_home_is_fleet_work test_retired_and_unsupported_coverage test_unsupported_forge_is_not_fleet_work test_held_unsupported_forge_is_not_captain_work test_shared_contribution_signal_wakes_once test_watcher_keeps_diagnostics_separate_from_contribution_wakes test_expired_child_unsupported_forge_stays_unmeasured test_watcher_surfaces_new_contribution_once test_home_summary_coverage test_unreadable_pending_is_not_empty test_budget_refusal_between_calls test_budget_bounded_call_timeout test_genuine_failure_near_deadline_is_unavailable test_issue_read_timeout_between_successes_stays_silent test_persistent_issue_read_timeout_wakes_when_stale test_shared_url_observed_once test_terminal_contribution_settles test_late_owner_inherits_terminal_observation test_done_task_open_pr_still_observed test_reservation_defers_later_url_when_fifteen_seconds_do_not_remain test_three_second_pr_reads_complete_fresh_in_one_cycle test_unavailable_forge_records_error_and_wakes_once_per_episode test_late_owner_keeps_failure_episode_suppressed; do
( "$test_name" ) || failures=$((failures + 1))
done
[ "$failures" -eq 0 ] || fail "$failures contribution regressions"