The autotrade exit pass (run_exits) treated a CLOB /book 404 on a resolved/closed market as a hard failure: it parked the exit cursor just before that event and re-failed it every pass forever — wedging the cursor and blocking every exit-event queued behind it. Fix b5d5986 (branch autotrade-foundation) reclassifies terminal markets as a cursor-advancing skip via a new pure exit_decision_for helper.
For Agents
Commit:b5d5986 on autotrade-foundation — NOT on main, NOT on VM (VM runs bf4b628). The live engine still has the wedge until merge + redeploy.
Files:market.rs (new pure helpers + tests), market_data.rs (resolve() short-circuit), runner.rs (exit-path classification), commands.rs (run_sell terminal message).
A single cohort exit-event for a resolved/closed market re-fails on every exit pass.
Telegram receives a repeated alert each pass for that event (spam).
Every exit-event queued behind the wedged one is never processed — once live, this blocks cohort-mirror exits for all positions behind the wedge.
Root Cause
run_exits (runner.rs) looped cohort exit-events and, on the first market whose CLOB /book returned 404 "No orderbook exists for the requested token id", did break / return false. resume_cursor then parked the autotrade_exits cursor at first_failed - 1µs.
The trap: a RESOLVED/CLOSED market returns that 404 permanently — there is no orderbook for a settled market, ever. So the next pass re-reads the same event, re-404s, re-parks the cursor at the same point, and re-alerts. The cursor wedges forever on that one event and never advances past it to the events behind.
Why this is a capital-trapping class of bug once live
The wedge is on the exit path. Positions behind the wedged event would never get their cohort-mirror exit fired, even when the source trader has already left. This is the same “exit path silently stops working” failure family as the SELL-never-sells lot-size blocker (see project_autotrade-engine-spec / round-2 review). The terminal-market 404 is the permanent-404 sub-case that turns a transient skip into an eternal wedge.
Fix (b5d5986)
Status-based classification instead of treating all /book errors alike.
market_data.rs — don’t even call book_top for terminal markets
resolve() short-circuits: if meta.status.resolved || meta.status.closed, it returns an emptyBookTop { best_bid: None, best_ask: None } instead of calling self.clob.book_top(&token_id).await?. So a terminal market resolves cleanly (no 404, no error) with an empty book, then assemble() builds a normal ResolvedMarket with best_bid = None.
market.rs — new pure helpers (unit-tested, const fn)
pub const fn is_terminal_market(status: &MarketStatusView) -> bool { status.resolved || status.closed}pub enum ExitDecision { Skip, Exit, Retry }impl ExitDecision { pub const fn advances_cursor(self) -> bool { matches!(self, Self::Skip | Self::Exit) }}pub const fn exit_decision_for(market: &ResolvedMarket) -> ExitDecision { if is_terminal_market(&market.status) { ExitDecision::Skip // settled separately, advance past it } else if market.best_bid.is_some() { ExitDecision::Exit // open + bid: sell (unchanged behaviour) } else { ExitDecision::Retry // open + no bid: halt + "will retry" }}
runner.rs — both exit paths classify, then branch
Both process_exit_event and drain_pending_exits call exit_decision_for(&resolved) and branch:
Decision
Condition
Action
Skip
terminal (resolved || closed)
advance cursor (or clear_exit_pending in the drain path); quiet tracing::debug!; no alert
Exit
open + best_bid.is_some()
sell — unchanged path
Retry
open + no bid, or a transient resolve() error
halt + "… will retry" alert
The key distinction: Skip and Exit both advances_cursor(); Retry does not (a genuinely transient condition still wedges-by-design so it’s retried, but a permanent terminal-market state now advances).
commands.rs — run_sell terminal message
For a terminal market, run_sell returns an informational string (ℹ #{id}: market resolved/closed — settled separately, no sell) instead of falling through to the "no bid, cannot sell" warning.
Where terminal positions actually close
Resolved/closed positions are not force-sold by the exit pass — they close via the separate settlement sweep. The exit pass’s only job for a terminal market is to get out of its own way (advance the cursor). The entry path is unaffected: liveness_gate already skips resolved/closed markets before reading best_ask, so no entry ever fires on a terminal market.
Gotcha — resolved and closed are the SAME field in the vendored CLOB client
is_terminal_market currently reduces to "closed"
In the vendored CLOB client (crates/polymarket-clob/src/lib.rs:251, ClobClient::status_from), both fields alias the single CLOB /markets field m.closed:
MarketStatus { resolved: m.closed, // same source closed: m.closed, // same source ...}
So status.resolved || status.closed is effectively status.closed today. The predicate is written as the OR for forward-correctness. If a future change populates resolved independently of closed, re-verify is_terminal_market and the exit/entry/settlement assumptions that ride on it.
Validation
Gate: 345 polymarket-fetch + 87 polymarket-data, 0 failures. Baseline was 335 → +10 new tests (terminal-skip decision, terminal-market empty-book assemble, is_terminal_market truth table, exit_decision_for Skip/Exit/Retry branches).
Trade-safety review: 7/7 pass. Confirmed: terminal predicate is resolved || closed only; a transient resolve() error still halts (does not silently advance); the circuit breaker is untouched by read errors; zero blast radius on entry / reconcile.
Deploy state
Not yet live
Committed to autotrade-foundationonly. Not on main, not on the VM (VM runs bf4b628). The live engine still has the exit-cursor wedge until this branch is merged and redeployed.