feat(activity): #200 · background pending poll + everywhere waiting cue#213
Conversation
The approval poller only ran while the operator was ON the Activity surface, so a dapp/agent request raised from Home/Settings/a wallet view silently expired (~120s TTL). Add a low-cadence (5s) whole-session PendingList poll (mirrors the balance poller: task handle dropped on lock, auth-epoch fenced) that feeds ONE shared Shell::waiting_count() behind the existing E6 home waiting strip + sidebar Activity badge — no new notification pattern, no navigation, no focus steal (passive cue per DESIGN trust rules). The two pollers never double-fetch: on the Activity surface the background tick SKIPs (the ~2s feed poller owns freshness) and refresh_activity reconciles the snapshot so stepping off the feed can't show a settled count. Pure state machine (pending_poll_tick / pending_waiting / waiting_count) unit-tested; only status==Pending rows count as waiting.
…oles Adversarial review (self-verified after the workflow's verify phase hit a session limit) surfaced four real defects in the first cut; the hung-round- trip guard-wedge it also raised is a pre-existing shared property of the blocking client (balance + activity pollers behave identically) and a wedged daemon can't act on approvals anyway — documented as a known limitation, not fixed here. Fixed: - P1: the home strip's 'Review →' did a raw open(Surface::Activity) — landing on a stale feed with the feed poller stopped AND the background poll SKIPping (it skips while Activity is open). The cue's own CTA buried the request it announced, all polling halted, until it expired. Now routes through open_activity (refresh + restart the feed poller), like every other feed path. - P3: lock() cleared polled_pending but not the in-flight ActivityFeed fetch — a reply landing post-lock re-installed a dead session's feed AND its polled_pending reconcile. Bump activity_epoch in lock() to fence it. - P3: two unsequenced writers of polled_pending — a background reply that raced a surface switch carried an older (pre-approve) daemon snapshot and could revive a just-settled count. Drop the background write while surface==Activity (the feed reconcile owns that state there). - P3: lock() left self.activity populated, so off-Activity waiting_count fell back to a previous session's feed count and flashed a phantom amber 'N waiting' after re-unlock until the first poll. Clear the feed snapshot on lock; the fallback now reads 0 (caught up) — the safe direction.
Live QA evidence (just qa vault, ask-me-everything preset, chain 31337). App sitting on Home, never navigated: a bridge personal_sign parks a Pending, and within one 5s background-poll interval the home waiting strip flips green 'Nothing waiting for you.' → amber '1 waiting for you · Review →' AND the sidebar Activity row gains its amber '1' badge — no navigation, no focus steal, no approve control (passive cue, DESIGN trust rules).
Screenshot evidence (acceptance #1) ✅Live QA ( Before — Home, caught up (green "Nothing waiting for you.", no sidebar badge): After — same Home surface, one poll interval later: amber "1 waiting for you · Review →" strip + amber "1" badge on the sidebar Activity row. No navigation, no focus steal, no approve control: (The Adversarial review — findings fixedRan a multi-lens review (3 Claude lenses: races / trust / regression). The verify + codex passes hit an upstream session limit, so I self-verified the raw findings against the code. Four were real and are fixed in
DoD status
|
…le source for the cue Codex (GPT-5, cross-model) found the P0/P1 my Claude-lens review missed: the Activity feed is daemon-capped at the newest 200 rows while PendingList is uncapped, so reconciling polled_pending from the feed (refresh_activity) could undercount to a phantom 0 and hide a real waiting request once >200 newer records exist — a wrong-direction cue. The same reconcile was also a second, unordered writer of polled_pending, racing the background reply. Fix: make the uncapped PendingList the SOLE source of polled_pending. Remove the feed-derived reconcile entirely (kills the cap-divergence AND the dual- writer race — kick_pending_refresh is now the single, in-flight-guarded writer). Preserve the 'leaving the feed is fresh' benefit by kicking one uncapped kick_pending_refresh on the Activity→elsewhere nav transition (should_kick_pending_on_nav, pure + unit-tested) in open()/select(). On the Activity surface waiting_count still reads the feed, so badge == the NEEDS YOU band beside it (both inherit the daemon cap — the underlying feed-cap-hides- pending-rows issue is a pre-existing daemon bug, out of scope for this app-only PR; worth a follow-up against the daemon). DoD: fmt clean; just check green (both configs); cargo test --workspace 437 passed (+1 nav-transition test).
Codex cross-model review (re-run after the session limit reset) ✅ + fixRan the codex review (GPT-5, high reasoning, read-only) against the full branch diff. It cleared the concurrency concern it was asked to break (the in-flight-guard wedge — verified safe: P0/P1 — feed-cap divergence (now fixed in
|


Closes #200. Part of #179.
Why
The approval/activity poller only ran while the operator was on the Activity surface, so a dapp or agent request raised from Home/Settings/a wallet view silently expired (approval TTL ~120s). The "waiting on you" promise now holds from any surface.
What
start_pending_poll, 5s cadence — well inside the 120s TTL): a whole-session loop that mirrorsstart_balance_poll(task handle dropped on lock,auth_epoch-fenced against fast re-unlock). It fetches onlyPendingList(a wire call the app already uses) and folds the still-Pendingcount intopolled_pending. First fetch fires immediately on unlock so a pre-parked request lights the cue the moment the shell renders.Skips (the existing ~2s feed poller owns freshness there — that poller is untouched), andrefresh_activityreconcilespolled_pendingfrom the fresh feed so stepping off Activity can never show a count a refresh just settled.Shell::waiting_count()now feeds BOTH the E6 home waiting strip (welcome.rs) and the sidebar Activity count badge (shell_chrome.rs). On the Activity surface the feed count wins (the badge must equal the NEEDS YOU band beside it); everywhere else the background snapshot wins, with the last feed snapshot as fallback until the first poll lands.shell.rs:pending_poll_tick(Stop/Skip/Fetch — encodes the two-pollers contract),pending_waiting(onlyApprovalStatus::Pendingcounts —Allowed/Denied/Expirednever cry wolf),waiting_count(source dispatch). 5 new tests inpending_poll_tests.Non-goals honored
No OS notifications, no approval-semantics/expiry changes, no daemon/wire changes, no new dependencies. Diff kept to the poll loop + the two render sites (coordination with #198, which touches
activity_view.rs— this PR does not).Definition of done
cargo fmt --all --check— cleanjust check— green (default AND--features tray)TMPDIR=/tmp cargo test --workspace— 436 passed, 2 ignored (39 suites)start_activity_poller(existing feed-poll tests untouched and green)personal_signraises the strip + badge within one poll interval without navigation