mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
109 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|