Context
Follow-up from PR #96 (the #90 grace-period fix). Both CI reviewers (Codex, Claude) independently flagged this; it is a pre-existing limitation of the reap-settle sweep design, not introduced by #96. #96 correctly closes the actual #90 High (the sub-second finalize→result window, which bites at normal volume). This issue is the separate, higher-load drainage gap.
The problem
storage.getReapableSharedJobs selects terminal targeted jobs whose snapshot->settlementContext IS NOT NULL, ordered oldest-first, LIMIT 200. It does not filter by the plugin's settlement status, and Core never clears settlementContext after a settlement terminalizes. So an already-settled job stays query-eligible forever (until it ages out of the 15-min lookback).
Consequence under sustained load: if more than ~200 terminal targeted jobs sit in the lookback window, every tick re-returns the same oldest 200 (mostly already-settled → settle() no-ops), and a genuinely-unsettled job sitting behind the batch cap can never be reached within the window. It then falls to the 26h leak-reaper, which refunds — the same money-loss class #90 was about, but gated behind throughput instead of a sub-second window.
- Trigger regime: >200 terminal targeted (paid
shared) jobs / ~14-min window, with those overflow jobs' complete-route settle() having failed (so they truly depend on the sweep).
- Current exposure: none in practice — no production paid
shared-dispatch volume yet (Phase B merged 2026-08-17). This is pre-production hardening for when marketplace volume exists.
What #96 already did (not a fix for this, but related)
- Ascending (oldest-first) order +
asc(id) tiebreaker → favors the rows nearest aging out, deterministic batch boundary.
- Honest saturation log: warns only when the batch is full and its oldest row is within 2 min of the lookback edge (a real "about to age out" signal), so operators get a heads-up before this bites.
Neither actually drains the backlog — they surface it and order it well.
Options for the real fix
- Settled-marker (preferred): a Core↔plugin signal so a terminalized settlement's job is excluded from the candidate set — e.g. plugin reports back, Core stamps
snapshot.settlement.done (or clears settlementContext), and the query filters it out. Keeps LIMIT honest.
- Settlement-status join: query excludes jobs whose plugin settlement row is already terminal (couples Core query to plugin schema — weaker boundary).
- Stable paging cursor: page through the full eligible window by
(completed_at, id) cursor instead of a fixed head-of-window LIMIT, so settled rows don't permanently occupy the budget.
Option 1 fits the Core-money-agnostic boundary best (Core carries an opaque marker; only the plugin interprets settlement).
Acceptance
- Under a synthetic backlog of >batch terminal targeted jobs/min with some unsettled overflow, every unsettled job is settled within the lookback window (none reaches the 26h leak-reaper for lack of a sweep slot).
- Core stays money-agnostic (no plugin-settlement-schema knowledge in Core queries), per the cycle-2 boundary.
Refs: PR #96, GitHub #90.
Generated with SMT smt@agora.build
Context
Follow-up from PR #96 (the #90 grace-period fix). Both CI reviewers (Codex, Claude) independently flagged this; it is a pre-existing limitation of the reap-settle sweep design, not introduced by #96. #96 correctly closes the actual #90 High (the sub-second finalize→result window, which bites at normal volume). This issue is the separate, higher-load drainage gap.
The problem
storage.getReapableSharedJobsselects terminal targeted jobs whosesnapshot->settlementContext IS NOT NULL, ordered oldest-first,LIMIT 200. It does not filter by the plugin's settlement status, and Core never clearssettlementContextafter a settlement terminalizes. So an already-settled job stays query-eligible forever (until it ages out of the 15-min lookback).Consequence under sustained load: if more than ~200 terminal targeted jobs sit in the lookback window, every tick re-returns the same oldest 200 (mostly already-settled →
settle()no-ops), and a genuinely-unsettled job sitting behind the batch cap can never be reached within the window. It then falls to the 26h leak-reaper, which refunds — the same money-loss class #90 was about, but gated behind throughput instead of a sub-second window.shared) jobs / ~14-min window, with those overflow jobs' complete-routesettle()having failed (so they truly depend on the sweep).shared-dispatch volume yet (Phase B merged 2026-08-17). This is pre-production hardening for when marketplace volume exists.What #96 already did (not a fix for this, but related)
asc(id)tiebreaker → favors the rows nearest aging out, deterministic batch boundary.Neither actually drains the backlog — they surface it and order it well.
Options for the real fix
snapshot.settlement.done(or clearssettlementContext), and the query filters it out. KeepsLIMIThonest.(completed_at, id)cursor instead of a fixed head-of-windowLIMIT, so settled rows don't permanently occupy the budget.Option 1 fits the Core-money-agnostic boundary best (Core carries an opaque marker; only the plugin interprets settlement).
Acceptance
Refs: PR #96, GitHub #90.
Generated with SMT smt@agora.build