Commit Graph
3 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 d82e5efeb9 perf(ui): cut repeated reads while a task is open (#20563)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps an open task current with a shared company
websocket and with polling fallbacks
> - In a busy company, one open task sent 35 to 60 API requests in 17
seconds, and each request waits on the database
> - Most of these requests were repeated reads: task summaries 22 times,
the task run list 11 times, task browsers 6 times, and the 60 KB agent
list 6 times
> - The cause is not the open task. Other agents' run and task events
invalidate company-wide lists on every 300 ms batch, and the task view
polls runs and browsers on fixed intervals even while the websocket is
open
> - This pull request spaces company-wide refreshes, and it lets the
websocket replace run and browser polling while it is connected
> - The benefit is about 79% fewer reads from an open task in a busy
company, with the same polling fallback when the connection is down

## Linked Issues or Issue Description

Refs #17608
Refs #9627

**What happened?**

An open task in a busy company sent the same reads again and again. In
one 17-second visit, the UI requested `issues/summaries` 22 times, the
task live runs 11 times, the task browsers 6 times, and the company
agent list 6 times. The company live runs and the task's child lists
(`parentId`, `createdFromIssueId`, `descendantOf`) were requested 2 to 3
times each.

**Expected behavior**

A busy company must not make an open task refetch company-wide lists on
every event batch. While the live connection is open, the UI must use
events, not fixed fast intervals, for task runs and browsers. The UI
must keep its polling fallback when the connection is closed.

**Steps to reproduce**

1. Open a task in a company where other agents run work.
2. Keep the task open for 17 seconds.
3. Count the API requests in the browser network panel.

**Paperclip version or commit**

Based on master at bd30ef8510.

## What Changed

- `ui/src/lib/query-invalidation-batcher.ts`: Add a minimum spacing
option to the invalidation batcher. A quiet lane flushes after the
normal batch interval. A busy lane flushes at most once per spacing
window. Add `createLaneRoutingBatcher`, which sends each invalidation to
one of two batchers by query key.
- `ui/src/context/LiveUpdatesProvider.tsx`: Route company-wide keys to a
5-second ambient lane. These keys are task lists and summaries, the
agent list, heartbeats, company live runs, sidebar badges, the
dashboard, costs and the company activity feed. All other keys, such as
task detail, comments, task runs and browsers, keep the 300 ms lane.
- `ui/src/lib/live-runs-cache.ts`: While the company connection is open,
a successful active run list or active run polls every 15 seconds
instead of every 1 or 3 seconds. Run lifecycle events already refresh
these queries, and progress events patch them.
- `ui/src/hooks/useTaskBrowsers.ts`: While the company connection is
open, stop browser polling when no browser can change state. Keep the
3-second poll for a starting, running, idle or stopping browser, and
when the connection is closed. The two callers pass the task's company.
Socket open and reconnect already reconcile this active query.
- `ui/src/lib/task-browsers-refresh.ts` and `LiveUpdatesProvider.tsx`:
On `browser.*` activity for a task, refresh that task's browser list. If
a read is in flight, one shared follow-up read runs after it settles, so
an older empty result cannot stop polling. Two views of the same task
share that follow-up. A list that no view observes becomes stale, so a
returning view reads it. When the connection opens or resyncs, each
browser list with a read in flight also gets one follow-up read, because
missed activity is not replayed.
- `doc/DATABASE.md`: Document the ambient lane and the new polling
contract.
- Tests: Add a fan-out test that replays a busy-company event stream for
17 seconds against an open task, plus unit tests for spacing, lane
routing, key classification, the run interval and the browser interval.
Update the recovery test for the connected active-run interval.

## Verification

Request count over a fixed 17-second open window
(`ui/src/context/LiveUpdatesProvider.fanout.test.ts`). The test mounts
the reads that an open, running task keeps active. It replays a busy
company: one run status event every second and one task activity event
every 0.7 seconds, none for the open task. Fake timers make each run
deterministic, so the spread is zero. The baseline used the same test
with master's batcher, run interval and browser interval.

| Read | Before | After |
| --- | ---: | ---: |
| Task summaries (sidebar) | 24 | 5 |
| Company agent list | 17 | 5 |
| Company live runs | 17 | 5 |
| Child list: parent | 24 | 5 |
| Child list: created from | 24 | 5 |
| Child list: descendants | 24 | 5 |
| Task live runs | 18 | 2 |
| Task browsers | 6 | 1 |
| **Total** | **154** | **33** |

The baseline numbers match the staging capture well (summaries 22,
browsers 6).

- `pnpm exec vitest run src/context/LiveUpdatesProvider*.test.*
src/lib/live-runs-cache.test.ts
src/lib/query-invalidation-batcher.test.ts
src/hooks/useTaskBrowsers.test.ts src/lib/task-browsers-refresh.test.ts
src/hooks/useTaskPluginPanels.test.tsx
src/components/task-side-panel/TaskSidePanel.test.tsx
src/pages/IssueDetail.test.tsx` (in `ui/`): all pass.
- `pnpm exec tsc --noEmit -p ui`: no errors in `ui/src`.
- `pnpm check:token-gates`: clean.
- Tests also prove these cases:
- With the connection closed, task runs still poll every second and
browsers every 3 seconds.
- With a quiet company, one event refreshes the summaries and child
lists after one normal batch.
- `browser.started` on the open task refreshes its browsers. The same
event on another task does not.
- A browser that starts during a slow empty read appears after that read
settles (`ui/src/lib/task-browsers-refresh.test.ts`). This test fails
when the refresh joins the older read.
- Two views and repeated events during one read cause one follow-up
read. This test fails without the shared queue.
- A list cached while the task was closed is read again when a view
returns.
- A connection that opens during an older empty browser read causes one
more read, so a browser that started while the socket was closed
appears. This test fails without the follow-up.
- Task detail, comments, task runs, browsers, attention and another
company's keys stay on the fast lane.
  - An unrecognized active result keeps the fallback interval.

This is a local request-count proof. It does not include a timed browser
journey on a deployed instance.

## Risks

- Company-wide lists refresh at most once every 5 seconds from live
events, so they can be up to 5 seconds late. After 5 quiet seconds, an
event refreshes them in about 300 ms. Own mutations invalidate their
queries directly, so they do not wait.
- An active task run refreshes from events. Fields without an event,
such as output silence, can be up to 15 seconds late while the
connection is open. Reconnect still reconciles missed events.
- A browser that appears without activity is not shown until the next
activity or reconnect. Every browser lifecycle step logs activity on the
task, so this case is not expected.
- No server, schema or API change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:01:53 -06:00
Devin FoleyandPaperclip e335265568 fix(ui): stop idle task run polling with live updates (#17608)
Stop successful empty assigned-task run queries from polling only while the matching company and session connection is open. Preserve active, pending, failed, and disconnected cadence. Share one lifecycle refresh owner between the task and chat views, discover reviewer events without task identity, and refresh after older in-flight reads settle.

All 175 focused tests, 9,079 UI tests, build, typecheck, exact-head Greptile 5/5, and required CI pass. Regression mutations catch reviewer discovery, duplicate reads, readiness, failure state, missing refreshes, and scope cleanup. Earlier E2E cleanup deadlock and server cleanup timeout remain documented; failed-only reruns passed on their same commits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 07:54:47 -07:00
DottaandClaude Opus 4.8 02a4f52277 perf(ui): event-source the company live-runs list (Phase 1) (#9627)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its web UI keeps live views fresh with React Query, coordinated by a
live-events websocket (`/api/companies/:id/events/ws`) and cross-tab
polling
> - We already cut the worst live-update churn (#9569, #9624), but the
deeper issue is that even the "push" path is *push-the-signal,
pull-the-data*: a websocket event triggers `invalidateQueries` → an HTTP
refetch
> - The company live-runs list (`queryKeys.liveRuns`) is the
most-observed resource — the sidebar renders it on nearly every page —
so its refetch is the most ambient source of churn, fired on every
`heartbeat.run.queued` / `heartbeat.run.status` event
> - Those events already carry enough (`runId`, `status`) to update the
cached list directly, so this pull request event-sources that list
instead of refetching it
> - The benefit is that the always-observed live-runs list stops
refetching on run lifecycle events — the first concrete step of the
push-over-poll redesign

## Linked Issues or Issue Description

No public GitHub issue exists; describing inline per CONTRIBUTING.md →
"Link Issues or Describe Them In-PR", following the bug report template.
This continues the memory/CPU-churn work from #9569 and #9624.

**What happened?**

Live agent-run tabs accrue high idle CPU and off-heap memory because
live-update events cause HTTP refetches. Profiling showed the company
live-runs list — observed on almost every page via the sidebar — being
refetched on every run status change, one of the most frequent ambient
refetches.

**Expected behavior**

A websocket event that already carries the changed data should update
the cached list directly, without an HTTP round-trip, so the
always-observed live-runs list does no refetch on routine run lifecycle
events.

**Steps to reproduce**

Open the app with agents running and watch the network panel: `GET
/api/companies/:id/live-runs` fires on each `heartbeat.run.status` /
`heartbeat.run.queued` event even though the event payload already
describes the change.

**Paperclip version or commit**

Branch `perf/live-runs-event-sourced`, off `master` (after #9624).

**Deployment mode**

Local dev (`pnpm dev`), web UI. Core UI live-updates plumbing; not
adapter-specific.

## What Changed

- **`ui/src/lib/live-runs-cache.ts` (new)** — pure `removeRunFromList` /
`patchRunStatusInList` helpers for the cached `LiveRunForIssue[]`.
- **`ui/src/context/LiveUpdatesProvider.tsx`** — on
`heartbeat.run.queued` / `heartbeat.run.status`, patch
`liveRuns(companyId)` in place instead of invalidating it:
  - terminal status → remove the run from the list,
  - status change on a run already in the list → update it in place,
- a genuinely new run (can't be reconstructed from the event) → fall
back to a single `invalidateQueries` refetch.
- Removed the blanket `liveRuns` invalidation from
`invalidateHeartbeatQueries`.
- On websocket **reconnect**, refetch `liveRuns` once to reconcile
events missed while disconnected (durable replay is a later phase).
- Other resources these events invalidate (`dashboard`, `costs`,
`sidebarBadges`, `agents.list`, agent detail) are unchanged — they're
lower-frequency / less-often-observed and are follow-up phases. This
keeps the change scoped and **client-only** (no server changes).

## Verification

- `vitest`: new `live-runs-cache.test.ts` (remove/patch/no-op/undefined)
and new lifecycle-handler cases in `LiveUpdatesProvider.test.ts`
(terminal→remove, present→patch, new→needs-refetch) via
`__liveUpdatesTestUtils`. All existing `LiveUpdatesProvider` tests still
pass (33 total across the two files).
- `tsc -b` clean.
- Runtime: the event-sourced path is covered by unit tests; end-to-end
refetch reduction should be re-measured against a rebuilt bundle with
the network panel / MCP instrumentation.

## Risks

Low, and client-only.
- **Staleness across a dropped connection:** an event missed while the
socket is down isn't replayed yet, so the reconnect handler refetches
`liveRuns` once to reconcile. Durable event replay (Last-Event-ID) is a
planned later phase; until then reconnect-reconcile covers the gap.
- **New-run fallback:** a genuinely new run still triggers one refetch
(it can't be reconstructed from the event alone), so no new runs are
missed.
- Aggregate resources (dashboard/costs/badges) are untouched and still
invalidate (already coalesced), so their behavior is unchanged.

Follow-up phases (from the design discussion): event-source
`activity`/comments and the remaining class-B resources; give pure-poll
resources events and drop their intervals; add durable event sequence +
reconnect replay; and a shared bus (Postgres `LISTEN/NOTIFY`) only when
the API tier scales to >1 replica.

## Model Used

- **Provider:** Anthropic, via the Claude Code CLI.
- **Model:** Claude Opus 4.8 (`claude-opus-4-8`).
- **Reasoning mode:** Extended thinking enabled.
- **Capabilities used:** tool use (shell, file editing), sub-agent
fan-out to inventory the client polling and server push infrastructure,
and the Chrome DevTools MCP to reproduce/profile the churn that
motivated this redesign.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work (a perf/plumbing change, not planned core feature
work)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (continues #9569 / #9624; no duplicates)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have considered and documented any risks above
- [ ] I have updated relevant documentation to reflect my changes (N/A —
no user-facing docs; rationale documented inline)
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:40:35 -05:00