Files
PaperClipAI/server/src/__tests__/chat-action-work.test.ts
T
DottaandPaperclip e733bddc45 perf(chat): stop polling provider action and session sync queues (#15822)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat stores pending provider actions in durable database rows.
> - Three action queues still scan every second when empty.
> - These scans keep idle databases active.
> - This pull request wakes those queues after a commit or at their next
deadline.
> - Empty queues stop querying while existing recovery rules remain in
place.

## Linked Issues or Issue Description

Refs #15808, #15802, #15625. Related sleep work: #15750, #15757.

**What existing behavior does this improve?**

Provider messages and notices, accepted GitHub webhook receipts, and
Slack session status synchronization.

**Current behavior**

Chat maintenance scans these three queues every second. It also rechecks
paused Slack sync rows every 30 seconds.

**Proposed behavior**

Reuse the transaction-aware action workers and existing chat_actions
rows. Wake after commits. Restore unfinished work on startup. Use stored
retry and stale-claim deadlines. Stop scanning when empty. Park paused
Slack sync rows until an endpoint change.

## What Changed

- Add three topics to the existing action worker registration. No
tables, migrations, triggers, or external services.
- Signal producers, claims, retry updates, terminal settlements, and
operator resolution inside their owning transaction. Provider I/O
remains outside transactions.
- Share pending-state and deadline predicates between dispatch and
scheduling. Exclude terminal history and uncertain provider sends.
- Preserve GitHub receipt attempt fences and provider-send quarantine.
Preserve Slack's 30-minute refresh while a session is processing.
- Remove these workers from periodic app maintenance. Update the sleep
inventory and add regression coverage.

## Verification

- Passed: 13 focused tests across scheduler, app wiring, and real
PostgreSQL integration. Covers actual commit wakes with deferred fast
paths disabled, GitHub retries and startup recovery, stale-send
quarantine, Slack publication-to-sync delivery, paused deadlines, and
idle queues.
- Passed: pnpm typecheck.
- Passed: full chat integration and related coordinator suites, **1,274
tests across five files**.
- Passed: pnpm build.
- Greptile: **5/5**, no actionable findings, completed on
`dc284cd275e5f6df624d60b27954421f2bcdbee2`.
- Hosted CI: **54 checks passed**, two intentionally skipped Storybook
checks, no failures or pending checks on
`dc284cd275e5f6df624d60b27954421f2bcdbee2`.

Provider responses are simulated. PostgreSQL, transactions, service
dispatch, and scheduling are real. No live Slack or GitHub App was used.

## Risks

- Writers must emit the existing process-local transaction signal.
Independent database clients and direct SQL still need explicit
integration or startup recovery.
- Accepted GitHub receipts are covered. Finding callbacks GitHub never
delivered still uses the separate provider recovery lane.
- Other chat maintenance and persistent transports still run. This does
not establish whole-instance sleep.
- Existing claim, revision, credential, and authorization checks remain
authoritative during concurrent foreground and scheduled dispatch.

## Model Used

OpenAI Codex (GPT-6), with repository inspection, code execution, and
automated tests.

## 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-11 06:08:05 -05:00

113 lines
6.0 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 { registerChatActionWork } from "../services/chat-action-work.js";
import { CHAT_ACTION_QUEUES, notifyChatActionWork, notifyChatEndpointWork, notifyChatVerificationWork } from "../services/chat-work-notifications.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), onError = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner, canRun: enabled, canReconcile: enabled, onError });
coordinators.push(coordinator);
const service = {
processPendingProviderEffects: vi.fn(async () => {}),
processPendingGitHubWebhookIngress: vi.fn(async () => {}),
processPendingSlackSessionSyncs: vi.fn(async () => {}),
processPendingSlackBoardMessages: vi.fn(async () => {}),
processPendingSlackTaskStarts: vi.fn(async () => {}),
processPendingReceiptReactions: vi.fn(async () => {}),
processPendingSlackSessionStops: vi.fn(async () => {}),
processPendingVerificationMessages: vi.fn(async () => {}),
nextChatActionAt: vi.fn(async (): Promise<number | null> => null),
nextVerificationMessageAt: vi.fn(async (): Promise<number | null> => null),
};
const runs = [service.processPendingProviderEffects, service.processPendingGitHubWebhookIngress, service.processPendingSlackSessionSyncs, service.processPendingSlackBoardMessages, service.processPendingSlackTaskStarts,
service.processPendingReceiptReactions, service.processPendingSlackSessionStops, service.processPendingVerificationMessages];
return { owner, service, runs, coordinator, enabled, onError, start: () => registerChatActionWork(coordinator, service, enabled) };
}
describe("chat action scheduling", () => {
it("does one startup scan per lane and no more SQL or timers during an idle hour", async () => {
const s = setup();
await s.start().ready;
await vi.advanceTimersByTimeAsync(3_600_000);
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(1);
expect(s.service.nextChatActionAt).toHaveBeenCalledTimes(7);
expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1);
expect(s.coordinator.nextWakeAt()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it("wakes each lane after its write settles and wakes parked work when an endpoint changes", async () => {
const s = setup();
await s.start().ready;
for (const kind of Object.keys(CHAT_ACTION_QUEUES) as (keyof typeof CHAT_ACTION_QUEUES)[]) {
await s.owner.transaction(async tx => {
await notifyChatActionWork(tx, kind);
expect(s.runs.every(run => run.mock.calls.length === 1)).toBe(true);
});
}
await s.owner.transaction(tx => notifyChatVerificationWork(tx));
await vi.advanceTimersByTimeAsync(1);
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(2);
await s.owner.transaction(tx => notifyChatEndpointWork(tx));
await vi.advanceTimersByTimeAsync(1);
for (const run of s.runs) expect(run).toHaveBeenCalledTimes(3);
expect(vi.getTimerCount()).toBe(0);
});
it("restores durable deadlines and reschedules when a foreground send moves a retry earlier", async () => {
const s = setup();
let due: number | null = Date.now() + 120_000;
s.service.nextChatActionAt.mockImplementation(async () => due);
await s.start().ready;
expect(s.coordinator.nextWakeAt()).toBe(due);
await vi.advanceTimersByTimeAsync(1000);
due = Date.now() + 30_000;
await s.owner.transaction(tx => notifyChatActionWork(tx, "receipt_reaction"));
await vi.advanceTimersByTimeAsync(1);
expect(s.coordinator.nextWakeAt()).toBe(due);
expect(s.service.processPendingReceiptReactions).toHaveBeenCalledTimes(2);
due = null;
await vi.advanceTimersByTimeAsync(30_000);
expect(s.service.processPendingReceiptReactions).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(120_000);
expect(s.coordinator.nextWakeAt()).toBeNull();
});
it("keeps lanes independent and retains a commit received during a drain", async () => {
const s = setup();
let release!: () => void;
s.service.processPendingSlackBoardMessages.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
const work = s.start();
await vi.advanceTimersByTimeAsync(1);
await s.owner.transaction(tx => notifyChatActionWork(tx, "slack_board_message"));
await s.owner.transaction(tx => notifyChatActionWork(tx, "slack_session_stop"));
await vi.advanceTimersByTimeAsync(1);
expect(s.service.processPendingSlackSessionStops).toHaveBeenCalledTimes(2);
release(); await work.ready;
await vi.advanceTimersByTimeAsync(1);
expect(s.service.processPendingSlackBoardMessages).toHaveBeenCalledTimes(2);
});
it("retries failed recovery and does no dispatch or deadline queries during standby", async () => {
const s = setup();
s.enabled.mockReturnValue(false);
await s.start().ready;
await vi.advanceTimersByTimeAsync(2000);
expect(s.service.nextChatActionAt).not.toHaveBeenCalled();
for (const run of s.runs) expect(run).not.toHaveBeenCalled();
s.enabled.mockReturnValue(true);
s.service.processPendingSlackTaskStarts.mockRejectedValueOnce(new Error("DB unavailable"));
await vi.advanceTimersByTimeAsync(1000);
expect(s.onError).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
expect(s.service.processPendingSlackTaskStarts).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
});