Files
PaperClipAI/server/src/__tests__/browser-use-work.test.ts
T
DottaandPaperclip 35511366dc perf(server): schedule browser cleanup only while sessions exist (#15757)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Hosted instances must become quiet when they have no work.
> - Browser Use queried its session table every three seconds, even when
empty.
> - Existing session rows already store the next poll time and cleanup
lease.
> - This pull request uses commit signals and those saved deadlines to
schedule cleanup.
> - Empty instances stop browser database scans. Live sessions keep
their safety checks.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Browser Use background cleanup and recovery. This follows #15625 and
#15750.

**Subsystem affected**

server/ — REST API and orchestration services.

**Current behavior**

The app scans for due sessions every three seconds, including instances
that have never used Browser Use.

**Proposed behavior**

Scan once at startup. Wake after session creation, provider-create
settlement, stop requests, or credential-removal cleanup. Schedule the
next check from `nextPollAt` and `leaseUntil`. Stop scheduling when all
sessions are terminal.

**Reason and benefit**

Remove empty database traffic so idle stacks and their database
connections can become quiet.

**Breaking changes**

No API or schema change. Running and idle sessions retain provider
polling, cost accounting, access checks, expiry checks, and cleanup
retries.

## What Changed

- Register browser cleanup with the existing work coordinator. Remove
the app's three-second interval.
- Signal existing session transactions before writes. No new tables or
provider infrastructure.
- Restore deadlines from live sessions and their leases. Wake after a
credential-removal sweep releases its lease.
- Wait for concurrent cleanup instead of returning while it still runs.
Drain and standby preserve deferred work.
- Add scheduler tests and database-backed commit and restart coverage.
Update the connector guide and sleep inventory.

## Verification

- Passed: focused browser worker and shared coordinator tests, 17 tests.
- Passed: browser lifecycle, route-scope, viewport, and transport tests,
50 tests. The lifecycle suite uses real local Postgres and a simulated
provider.
- Passed: `pnpm -r typecheck` and `pnpm build`.
- Passed: all hosted test groups for this commit. Started full local
`pnpm test:run`, then stopped its duplicate serial run after the
complete hosted sharded suite passed; no full local-suite pass is
claimed.
- Passed: direct server TypeScript check. Greptile reviewed
`99c3217a2dea505be94081e729941e542e6fbc03` at 5/5 with no findings. All
54 applicable hosted checks passed; the two Storybook checks were
skipped because this PR has no UI changes.

## Risks

- Process-local signals cover services that share the instrumented
database owner. Independent processes and direct SQL writes require
startup recovery; this does not add cross-process notification.
- Live sessions still poll. This removes empty scans; it does not make a
running hosted browser sleep.
- Cleanup uses the existing provider protocol and lease fences. Tests
simulate the provider; no paid live browser was created for this
scheduling change.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The
exact deployment model ID and context window are not exposed in this
session.

## 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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-10 07:06:31 -05:00

109 lines
4.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { installDatabaseWorkSignals } from "../../../packages/db/src/work-signals.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../services/delivery-work-notifications.js";
import { registerBrowserUseCleanup } from "../services/browser-use-work.js";
const coordinators: ReturnType<typeof createDeliveryWorkCoordinator>[] = [];
afterEach(async () => {
for (const coordinator of coordinators.splice(0)) await coordinator.stop();
vi.useRealTimers();
});
function setup() {
vi.useFakeTimers();
const execute = vi.fn(async () => [{ xid: "42", status: "committed" }]);
const owner = installDatabaseWorkSignals({ execute, async transaction(callback: (tx: any) => Promise<any>) {
return callback({ execute });
} });
const enabled = vi.fn(() => true), primary = vi.fn(() => true), onError = vi.fn();
const coordinator = createDeliveryWorkCoordinator({
owner, canRun: () => enabled() && primary(), canReconcile: primary, onError,
});
coordinators.push(coordinator);
const service = { sweep: vi.fn(async () => {}), nextSweepAt: vi.fn(async (): Promise<number | null> => null) };
const start = () => registerBrowserUseCleanup(coordinator, service, enabled);
const commit = () => owner.transaction(tx => notifyDeliveryWork(tx, DELIVERY_QUEUES.browser));
return { service, coordinator, start, commit, enabled, primary, onError };
}
describe("browser cleanup scheduling", () => {
it("stays quiet after an empty startup, wakes on commit, and stops after the final session closes", async () => {
const s = setup();
await s.start().ready;
await vi.advanceTimersByTimeAsync(3_600_000);
expect(s.service.sweep).toHaveBeenCalledTimes(1);
expect(s.service.nextSweepAt).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
const due = Date.now() + 15_000;
s.service.nextSweepAt.mockResolvedValue(due);
await s.commit();
await vi.advanceTimersByTimeAsync(1);
expect(s.service.sweep).toHaveBeenCalledTimes(2);
expect(s.coordinator.nextWakeAt()).toBe(due);
await vi.advanceTimersByTimeAsync(14_998);
expect(s.service.sweep).toHaveBeenCalledTimes(2);
s.service.nextSweepAt.mockResolvedValue(null);
await vi.advanceTimersByTimeAsync(1);
expect(s.service.sweep).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
});
it("restores a future lease/retry deadline at startup and lets a control commit preempt it", async () => {
const s = setup();
const due = Date.now() + 180_000;
s.service.nextSweepAt.mockResolvedValue(due);
await s.start().ready;
await vi.advanceTimersByTimeAsync(120_000);
expect(s.service.sweep).toHaveBeenCalledTimes(1);
await s.commit();
await vi.advanceTimersByTimeAsync(1);
expect(s.service.sweep).toHaveBeenCalledTimes(2);
expect(s.coordinator.nextWakeAt()).toBe(due);
});
it("retries a failed scan and bounds already-due batches", async () => {
const s = setup();
s.service.sweep.mockRejectedValueOnce(new Error("database unavailable"));
await s.start().ready;
expect(s.onError).toHaveBeenCalledTimes(1);
s.service.nextSweepAt.mockResolvedValue(Date.now() - 1000);
await vi.advanceTimersByTimeAsync(3000);
expect(s.service.sweep).toHaveBeenCalledTimes(2);
expect(s.coordinator.nextWakeAt()).toBe(Date.now() + 1000);
s.service.nextSweepAt.mockResolvedValue(null);
await vi.advanceTimersByTimeAsync(1000);
expect(vi.getTimerCount()).toBe(0);
});
it("does no session SQL during standby or drain and recovers a first commit after drain", async () => {
const s = setup();
s.primary.mockReturnValue(false);
await s.start().ready;
await vi.advanceTimersByTimeAsync(6000);
expect(s.service.sweep).not.toHaveBeenCalled();
expect(s.service.nextSweepAt).not.toHaveBeenCalled();
s.primary.mockReturnValue(true);
s.enabled.mockReturnValue(false);
await s.commit();
await vi.advanceTimersByTimeAsync(6000);
expect(s.service.sweep).not.toHaveBeenCalled();
expect(s.service.nextSweepAt).not.toHaveBeenCalled();
s.enabled.mockReturnValue(true);
await vi.advanceTimersByTimeAsync(3000);
expect(s.service.sweep).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("waits for in-flight cleanup on shutdown without rearming its deadline", async () => {
const s = setup();
let finish!: () => void;
s.service.sweep.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const worker = s.start();
await vi.advanceTimersByTimeAsync(1);
let stopped = false;
const stop = s.coordinator.stop().then(() => { stopped = true; });
await Promise.resolve();
expect(stopped).toBe(false);
finish();
await stop;
await worker.ready;
expect(s.service.nextSweepAt).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
});