Skip to content

fix(buyer): an unanswered relay read is not proof the offer is gone (#291) - #298

Merged
orveth merged 1 commit into
devfrom
fix/291-fetch-timeout-contract
Jul 30, 2026
Merged

fix(buyer): an unanswered relay read is not proof the offer is gone (#291)#298
orveth merged 1 commit into
devfrom
fix/291-fetch-timeout-contract

Conversation

@orveth

@orveth orveth commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #291.

The conflation

fetch_events resolves Ok(empty) on timeout. So in the auto-award loop, "the relay holds no offer for this job" and "we stopped waiting" arrived as the same bytes, and the loop read them as the first:

let Some(offer) = view.offer.as_ref() else {
    mark_award_parked(job_id, "offer no longer on the relay", ...);

parked is terminal — the driver never retries it. In the field that reason was false in every clause: the offer was on the relay with 5.8 hours left on its deadline, and the seller had already claimed. Twenty-five lines up, the award-presence check already treats its empty read as unknown. Two sites, identical evidence, opposite conclusions, and only one of them right.

The fix

Ask for the discriminator, because it is one layer out. JobView gains read_confirmed:

  • any event returned by any of the three filters proves the session was serving us — free
  • when all three come back empty, the fetch spends one round trip on a limit(0) REQ and waits for the EOSE the relay owes us

Reusing probe_relay_serves_our_reqs, the buyer's existing liveness probe. Deliberately a response, not a broadcast: an event we merely hope to receive proves nothing by its absence.

plan_missing_offer is the only place those two facts become an action, so the two sites cannot drift apart again:

read budget action
answered ParkOfferAbsent — absence established, park is honest
unanswered remains Retry — conclude nothing
unanswered spent ParkUnreadable — park on the read, never the offer

The bound is a floor, not a fallback to the old behaviour. Refusing forever is an infinite loop, because the deadline check that terminates the normal path lives inside the branch that requires an offer and is never reached on an empty read. A ceiling without a floor is half a fix — but the floor parks on what was observed (N consecutive job reads went unanswered by the relay; the offer's presence was never established either way), which is the one thing that stops it from reintroducing the defect a minute later.

Park reasons live beside the decision rather than at the call site, so a row's reason and the evidence that produced it cannot drift.

Second defect, also from the issue

The intent row was advanced only by the auto path, so after a manual award status went on reporting parked / offer no longer on the relay for a job that was awarded and running. Both success shapes now clear it — AlreadyAwarded included, since a parked reason sitting beside a recorded award is false whichever call published it.

Evidence

baseline   running 655 tests   test result: ok. 652 passed; 0 failed; 3 ignored
with fix   running 661 tests   test result: ok. 658 passed; 0 failed; 3 ignored

Denominator moves 655 → 661, so the six new tests actually ran rather than being filtered to a vacuous ok.

Red-proved. Reintroducing the defect (if read_confirmedif true) fails exactly the two guards and no others:

an_exhausted_retry_budget_parks_on_the_read_not_the_offer ... FAILED
an_unanswered_read_does_not_park_the_job                  ... FAILED
a_confirmed_empty_read_still_parks                        ... ok      <- red leg holds
test result: FAILED. 30 passed; 2 failed; 629 filtered out

That third line is the point of the suite. "A timed-out read must not park" is trivially satisfied by never parking, and a suite testing only the timeout leg would pass on that — a_confirmed_empty_read_still_parks is the positive control showing the guard discriminates rather than disables. Restore verified by grep before the green re-run, not assumed.

The reason-wording test is anchored on absence (no longer on the relay, no offer, offer is gone, expired must not appear) plus two positives, because the original defect was a reason asserting what the evidence could not support — and a presence-only check passes on a string that also contains the bad words.

ⓘ Not a timeout raise. Raising 5s is mitigation; a slower relay day reopens it. Per #253 the five fetch timeouts stay distinct — some are bounded above by an MCP deadline, others below by relay latency.

cargo fmt --check reports 748 drift sites crate-wide on dev and no workflow gates on it, so this branch leaves formatting alone rather than shipping a whole-crate reflow inside a behaviour fix.

🤖 Generated with Claude Code

…291)

`fetch_events` resolves `Ok(empty)` on timeout, so "the relay holds no offer
for this job" and "we stopped waiting" reached the auto-award loop as the same
bytes. The loop read them as the first and parked — terminally, since the
driver never retries a parked row — under the reason "offer no longer on the
relay". In the field that reason was false in every clause: the offer was
present with 5.8 hours left on its deadline and the seller had already claimed.

The discriminator is one layer out, so it is now asked for. `JobView` carries
`read_confirmed`: any event returned proves the session was serving us, and
when all three filters come back empty the fetch spends one round trip on a
`limit(0)` REQ and waits for the `EOSE` the relay owes us. A response, never a
broadcast — an event we merely hope to receive proves nothing by its absence.

`plan_missing_offer` is the single place those two facts become an action, so
the two sites that disagreed about identical evidence cannot drift apart again.
Refusing without a bound would be an infinite loop, because the deadline check
that terminates the normal path lives inside the branch that requires an offer;
the bound parks on the READ and its reason says so, rather than smuggling the
old conclusion back in a minute later.

Also clears the intent row on a manual award. It was advanced only by the auto
path, so `status` went on reporting a parked reason for jobs that were awarded
and running.

Tests: 655 -> 661. Red-proved by reintroducing the defect — the two guards fail,
and `a_confirmed_empty_read_still_parks` stays green, so the fix discriminates
rather than disables.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mobee Ready Ready Preview Jul 30, 2026 8:12am

Request Review

@orveth
orveth merged commit b217897 into dev Jul 30, 2026
5 of 6 checks passed
@orveth
orveth deleted the fix/291-fetch-timeout-contract branch July 30, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant