Context
LedgersPage (apps/loopover-miner-ui/src/routes/ledgers.tsx:429-449, consumed by GovernorControlSection at :465-468) reads governor pause state from two independent sources with no ordering guard between them:
const { result: polledPauseState } = usePolledFetch(loadGovernorPauseState, pollIntervalMs);
if (polledPauseState !== lastPolledPauseState) {
setLastPolledPauseState(polledPauseState);
setPauseState(polledPauseState); // render-time sync, no ordering guard
}
const runGovernorAction = (action: () => Promise<GovernorPauseStateResult>) => {
setActionPending(true);
void action().then((next) => {
setPauseState(next); // independent write, races the poll above
setActionPending(false);
});
};
pauseGovernor/resumeGovernor (the Pause/Resume buttons in GovernorControlSection) is a completely independent POST from the usePolledFetch(loadGovernorPauseState, …) GET poll — they don't share usePolledFetch's single-flight guard. If a poll GET is already in flight when the operator clicks Pause, and the POST resolves faster (plausible — it's a small local write vs. a read that may compete for the same SQLite file), the poll's stale pre-pause response can land after the action's fresh result and clobber it. The operator sees the UI revert to "Not paused" even though the governor is actually paused server-side, until the next poll tick self-corrects (up to DEFAULT_POLL_INTERVAL_MS, 10s). Same issue in reverse for Resume.
Verified empirically by porting the exact state-transition logic into a standalone Node script with realistic async interleaving (poll GET takes 180ms, action POST takes 50ms, action fires 20ms after the poll starts):
[action] pauseState <- action result: paused=true
[render-sync] pauseState <- polled: paused=false
Final client pauseState.paused: false
Actual server paused state: true
MISMATCH
This is a UI display bug only — it does not change what the governor's own pause/resume logic actually does server-side, only how quickly/reliably the dashboard reflects it.
Untested branch: apps/loopover-miner-ui/src/ledgers.test.tsx has separate tests for clicking Pause and for the polling cadence, but nothing exercises the two concurrently — the exact interleaving that triggers this bug is uncovered.
Requirements
Add an ordering/generation guard so a poll response that started before the most recent action cannot overwrite that action's result — mirror the cancellation/generation discipline use-polled-fetch.ts and use-streaming-text.ts already use internally for their own guards. Do not change how pauseGovernor/resumeGovernor themselves work server-side — this is a client-side state-ordering fix only.
Deliverables
Test Coverage Requirements
apps/loopover-miner-ui is not covered by the src/** 99% patch gate - the new regression test is this issue's own coverage deliverable.
Expected Outcome
Clicking Pause or Resume on the governor never has the button/state visually revert due to a stale, already-superseded poll response landing after the action's own result.
Links & Resources
apps/loopover-miner-ui/src/routes/ledgers.tsx:429-449,465-468, apps/loopover-miner-ui/src/lib/use-polled-fetch.ts (the cancellation/generation pattern to mirror), apps/loopover-miner-ui/src/routes/portfolio.tsx's runQueueAction (a partial precedent — routes its refresh through the same poll instance rather than a second state write, though not fully race-proof either)
Context
LedgersPage(apps/loopover-miner-ui/src/routes/ledgers.tsx:429-449, consumed byGovernorControlSectionat:465-468) reads governor pause state from two independent sources with no ordering guard between them:pauseGovernor/resumeGovernor(the Pause/Resume buttons inGovernorControlSection) is a completely independentPOSTfrom theusePolledFetch(loadGovernorPauseState, …)GETpoll — they don't shareusePolledFetch's single-flight guard. If a pollGETis already in flight when the operator clicks Pause, and the POST resolves faster (plausible — it's a small local write vs. a read that may compete for the same SQLite file), the poll's stale pre-pause response can land after the action's fresh result and clobber it. The operator sees the UI revert to "Not paused" even though the governor is actually paused server-side, until the next poll tick self-corrects (up toDEFAULT_POLL_INTERVAL_MS, 10s). Same issue in reverse for Resume.Verified empirically by porting the exact state-transition logic into a standalone Node script with realistic async interleaving (poll GET takes 180ms, action POST takes 50ms, action fires 20ms after the poll starts):
This is a UI display bug only — it does not change what the governor's own pause/resume logic actually does server-side, only how quickly/reliably the dashboard reflects it.
Untested branch:
apps/loopover-miner-ui/src/ledgers.test.tsxhas separate tests for clicking Pause and for the polling cadence, but nothing exercises the two concurrently — the exact interleaving that triggers this bug is uncovered.Requirements
Add an ordering/generation guard so a poll response that started before the most recent action cannot overwrite that action's result — mirror the cancellation/generation discipline
use-polled-fetch.tsanduse-streaming-text.tsalready use internally for their own guards. Do not change howpauseGovernor/resumeGovernorthemselves work server-side — this is a client-side state-ordering fix only.Deliverables
LedgersPage's governor pause-state sync inapps/loopover-miner-ui/src/routes/ledgers.tsxignores a poll response that resolves after a more recent action's result has already been applied.ledgers.test.tsxexercising the exact interleaving from the bug report (action resolves before an in-flight poll) and asserting the UI reflects the action's result, not the stale poll.Test Coverage Requirements
apps/loopover-miner-ui is not covered by the src/** 99% patch gate - the new regression test is this issue's own coverage deliverable.
Expected Outcome
Clicking Pause or Resume on the governor never has the button/state visually revert due to a stale, already-superseded poll response landing after the action's own result.
Links & Resources
apps/loopover-miner-ui/src/routes/ledgers.tsx:429-449,465-468,apps/loopover-miner-ui/src/lib/use-polled-fetch.ts(the cancellation/generation pattern to mirror),apps/loopover-miner-ui/src/routes/portfolio.tsx'srunQueueAction(a partial precedent — routes its refresh through the same poll instance rather than a second state write, though not fully race-proof either)