Skip to content

fix(pending-questions): pollers reuse one room read instead of a socket per poll - #5644

Open
qingyun-wu wants to merge 1 commit into
mainfrom
fix/pending-questions-poll-reuse
Open

qingyun-wu wants to merge 1 commit into
mainfrom
fix/pending-questions-poll-reuse

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What

Pollers of the owner's pending questions reused nothing: every gather runs the
pending-questions adapter's serve, which opens the owner room's ?kind=db socket, syncs,
settles 1 s and closes. Since #5027 moved the store into a room database (merged
2026-10-03 17:48Z), two pollers do that on every tick:

  • the web client's setInterval(..., 3000) on GET /tasks/active (src/web-client.ts) →
    _active_tasks_payload → _pending_gather() (src/agent-api.py);
  • the dashboard's 15 s location.reload() → get_pending_count() (src/dashboard.py).

That is ~24 db socket opens a minute per agent with either page open.

This PR adds gather_recent / count_recent to src/pending_questions_reader.py, the one
core reader both edges already go through:

  • a read no older than POLL_REUSE_SEC (60 s) is reused;
  • concurrent callers wait on the one in-flight read instead of starting their own;
  • resolve and reconcile_pass through the reader drop what it held, and a write that
    lands while a read is in flight keeps that read from being stored.

/tasks/active and the dashboard count use it. /questions/queue (shown by a person),
/answer, and the one-shot readers (briefing, friction, obsidian-mirror, CLI) still read fresh.
A question filed by another process shows up in the polled views within 60 s.

Why: prd evidence (room-doc logs, read-only)

Agent socket opens per day (room-doc:open … (agent)):

2026-10-02    379
2026-10-03  4,480
2026-10-04 14,878
2026-10-08 35,138
2026-10-12 51,084
2026-10-15 50,558

The step starts the evening of 10-03, a few hours after #5027 merged. On 10-15, sockets by
kind: db 34,506, html 16,379, markdown 1,700, other kinds <1k each. Each socket lives
~0.2–1 s (connection open → connection closed), which matches serve's
settle(1.0). Per-agent db cadence matches the two pollers: 2.4–3 s gaps
(vanessaxiaofen-sutando.agent, ruiwangwarm, selfsoothingcare, vidhu22 agents) is the 3 s
/tasks/active poll; 15–21 s (sutando-sonichi at a steady ~21 s, kchen6red-m1max.agent) is
the 15 s dashboard reload plus the render it waits on.

Before / after: opens per minute in a local loop

poll_loop.py loads agent-api.py and dashboard.py from a checkout, injects an adapter
whose every gather opens one TCP connection to a local counting listener (standing in for
serve's socket), and polls /tasks/active every 3 s plus the dashboard every 15 s for a
real 60 s:

parent (origin/main dbb0d45f2): 20 /tasks/active polls + 4 dashboard reloads in 60 s -> 24 socket opens/min
HEAD:                           20 /tasks/active polls + 4 dashboard reloads in 60 s -> 1 socket opens/min

Tests

New tests/pending-questions-poll-reuse.test.py: a 3 s poll for a minute opens once; the window
expires; count_recent matches count; gather is never reused; resolve and
reconcile_pass invalidate; a write during an in-flight read is not cached; 8 concurrent
pollers share one read; callers get copies; an unreachable room stays unknown; /tasks/active
x20 = 1 open while /questions/queue stays fresh; dashboard x10 = 1 open.

parent: Ran 12 tests ... FAILED (failures=2, errors=10)
HEAD:   Ran 12 tests ... OK

Existing tests updated where they stand in for the reader: the dashboard delegate test now
asserts count_recent, and two agent-api tests that patch resolve/gather call
forget_recent as the real resolve does. All tests/agent-api-*, tests/dashboard-* and
tests/pending-questions-* pass at HEAD; scripts/review-checks.sh passes.

Expected reduction

Per agent with the web client and dashboard open: 24 → 2 db opens a minute (agent-api and the
dashboard are separate processes, one read each per minute), about 90%. Fleet-wide, the db
share (~34.5k of ~57.5k opens on 10-15) should fall to a few thousand a day.

Not addressed here (not our code): the remaining html volume is two agents re-reading one
page each on a loop, html-2rbfbqzw in !IPVrHdzViRcaITCWZX every ~11.6 s since 10-02 and
html-ozphai8q in !duvkIeoPZtNujenOSb every ~6–13 s since 10-07, about 15k opens a day
between them.

🤖 Generated with Claude Code

…et per poll

Every pending-questions gather runs the adapter's `serve`, which opens the
owner room's `db` socket, syncs it, settles 1 s and closes. Since the store
moved into a room database, the web client's 3 s /tasks/active poll and the
dashboard's 15 s reload each did that on every poll: about 24 db socket opens
a minute per agent with either page open.

The reader now offers gather_recent / count_recent: a read no older than
POLL_REUSE_SEC (60 s) is reused, concurrent callers wait on the one in-flight
read, and resolve / reconcile_pass through the reader drop what it held (a
write that lands during a read keeps that read from being stored).
/tasks/active and the dashboard count use it; the triage queue, /answer and
the one-shot readers stay fresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/agent-api.py (100%)
  • src/dashboard.py (100%)
  • src/pending_questions_reader.py (100%)

Summary

  • Total: 42 lines
  • Missing: 0 lines
  • Coverage: 100%

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change-request condition fires at 32387051c. What's missing is a review: the PR has none (reviewDecision: REVIEW_REQUIRED) and no unresolved threads. An expert on the pending-questions reader or agent-api should give a substantive approval.

What I checked

  • Read the whole diff in src/pending_questions_reader.py, src/agent-api.py and src/dashboard.py.
    • /tasks/active and the dashboard count now use the cached read.
    • /questions/queue, /answer and the one-shot readers still call gather directly (src/agent-api.py:480,502,541; src/obsidian-mirror.py:175; src/morning-briefing.py:673; src/friction-detector.py:53).
    • answer_question closes through pending_questions_reader.resolve, so in agent-api a resolved question leaves the next /tasks/active poll straight away.
  • The guard against a write landing during a read looks right. The counter is read under the per-key lock before the gather, and forget_recent raises it. A write either during the read, or after the adapter returns but before forget_recent, stops the result being stored. That errs on the side of a fresh read.
  • Ran the PR's tests with SUTANDO_WORKSPACE_DIR and SUTANDO_TASKS_DIR unset:
    • tests/pending-questions-poll-reuse.test.py: parent dbb0d45 source gives FAILED (failures=2, errors=10); HEAD gives OK (12 tests). This matches the PR body.
    • All 22 tests/pending-questions-*.test.py, plus tests/agent-api-pending-questions.test.py and tests/dashboard-usecase-empty-state.test.py, pass at HEAD.
  • Changed the code six ways to see which changes the new suite catches. Five fail it as they should:
    • always storing the read (in-flight write guard removed);
    • no forget_recent in reconcile_pass;
    • no forget_recent in resolve;
    • no per-key lock;
    • no expiry.
  • The new tests exercise the real module through a counting adapter. None of them grep source or reimplement the function under test.
  • Looked for other pollers the PR might have missed. /questions/queue is fetched only when a person opens the queue (src/web-client.ts:3645), and the other setInterval pollers in src/ don't hit a gather route. No sibling site was left out.
  • Added lines contain no hardcoded paths. All CI checks pass. license/cla is present and success (one status context). No other open PR covers the same change.

Non-blocking notes

  1. src/pending_questions_reader.py:180: the copy made on a cache hit isn't covered by any test. Changing it to return hit[1] still passes everything. test_a_caller_cannot_change_what_the_next_one_gets (tests/pending-questions-poll-reuse.test.py:135) clears the first, uncached result, which was copied on the other path. A third gather_recent after changing a cached result would cover it.
  2. src/pending_questions_reader.py:161: on macOS, time.monotonic stops while the machine sleeps. A read cached before sleep can still be served for up to 60 s of awake time after wake, even if hours have passed. The delay is still at most 60 s, so this is a note, not an ask.
  3. A failed read ("room unreachable") is also kept for 60 s. The test treats this as intended, so a room that comes back can show as unknown for up to a minute. That seems fine; it just might be worth one sentence in the PR body.
  4. tests/dashboard-usecase-empty-state.test.py only rewords a docstring and renames a test, which has nothing to do with this fix. It's harmless, but it is a second concern in a single-concern PR.

Not verified

  • No live round trip after a restart. The PR measures socket opens with a local poll_loop.py harness, not a restarted agent-api and dashboard against a real room. Agent-api serves live requests, so that round trip is still owed. Per our policy it is noted here, not blocking.
  • I did not re-measure the prd room-doc open counts quoted in the PR body.

— Sutando (susans-MacBook-Pro)

@qingyun-wu
qingyun-wu requested a review from marklysze November 2, 2026 09:10

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 32387051 (unchanged since my 2026-10-17 review). Verdict: comment. No change-request condition fires; not approving because there is no expert approval.

Read again at this head

  • gather_recent (src/pending_questions_reader.py) reuses a gather for up to 60 s per (workspace, adapter). Concurrent callers wait on one per-key lock. A forget_recent during a read bumps the generation, so that read is not stored. resolve and reconcile_pass call forget_recent in finally.
  • Only the two pollers switch: /tasks/active (_pending_gather_polled) and the dashboard count (count_recent). /questions/queue, /answer and the one-shot readers still call gather.
  • The tests drive the real module through a counting adapter; no source-grep and no in-file copy of the unit.
  • CI green, license/cla: success (1 context), no unresolved threads, no added hardcoded paths. Still MERGEABLE with main.

New since my last review (non-blocking): main has since added clicks / due_clicks / ack to the same reader (#5946). ack is a write through this module that this PR does not invalidate. Today it is only called from the CLI, a separate process, so the agent-api cache is not affected beyond the 60 s staleness the PR already accepts across processes. If agent-api ever calls ack in-process, it should call forget_recent like resolve.

Still missing before approval

  • A substantive APPROVED review from a write-access expert on the pending-questions reader or agent-api. The PR has no reviews other than mine.

Owed, not blocking: a live round trip with agent-api and the dashboard restarted against a real room. The socket-count evidence comes from a local harness.

— Sutando (susans-MacBook-Pro)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants