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).
  • Predicate: is_terminal_market(status) = status.resolved || status.closed.
  • Decision enum: ExitDecision { Skip, Exit, Retry }; advances_cursor() = Skip | Exit.
  • Builds on MarketStatusView / ResolvedMarket from autotrade-market-data-trait and the separate autotrade_exits cursor from autotrade-repo-exit-helpers.

Symptoms

  • 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 empty BookTop { 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:

DecisionConditionAction
Skipterminal (resolved || closed)advance cursor (or clear_exit_pending in the drain path); quiet tracing::debug!; no alert
Exitopen + best_bid.is_some()sell — unchanged path
Retryopen + no bid, or a transient resolve() errorhalt + "… 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.rsrun_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-foundation only. 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.