mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
## 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>
98 lines
5.6 KiB
TypeScript
98 lines
5.6 KiB
TypeScript
import { and, eq, inArray, lte, or, sql } from "drizzle-orm";
|
|
import { chatActions, chatEndpoints, type Db } from "@paperclipai/db";
|
|
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
|
import { CHAT_ACTION_QUEUES, type ScheduledChatAction } from "./chat-work-notifications.js";
|
|
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
|
import { registerChatQueueWork } from "./chat-delivery-work.js";
|
|
|
|
export const SLACK_COMMAND_POST_STALE_MS = 60_000;
|
|
export const SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS = 5 * 60_000;
|
|
export const SLACK_COMMAND_ADMISSION_STALE_MS = 60_000;
|
|
export const PROVIDER_EFFECT_STALE_MS = 60_000;
|
|
|
|
/** The dispatch predicate and scheduler use the same eligibility and deadlines.
|
|
* Zero means ready now; null means terminal or waiting for an external change. */
|
|
export function chatActionDeadline(kind: ScheduledChatAction) {
|
|
const updated = sql`extract(epoch from ${chatActions.updatedAt}) * 1000`;
|
|
const retry = sql`coalesce(extract(epoch from (${chatActions.result}->>'retryAt')::timestamptz) * 1000, 0)`;
|
|
if (kind === "slack_board_message") return sql<number>`case
|
|
when ${chatActions.status} = 'received' and ${chatEndpoints.status} not in ('paused', 'attention') then 0::numeric end`;
|
|
if (kind === "slash_task_start") return sql<number>`case
|
|
when ${chatActions.status} = 'queued' and (${chatEndpoints.id} is null or ${chatEndpoints.status} in ('verifying', 'active', 'archived')) then ${retry}
|
|
when ${chatActions.status} = 'provider_confirmed' and (${chatEndpoints.id} is null or ${chatEndpoints.status} in ('verifying', 'active', 'archived')) then ${retry}
|
|
when ${chatActions.status} = 'admitting' then ${updated} + ${SLACK_COMMAND_ADMISSION_STALE_MS}
|
|
when ${chatActions.status} = 'resolving' then ${updated} + ${SLACK_COMMAND_EXPLICIT_RETRY_STALE_MS}
|
|
when ${chatActions.status} = 'validating' then ${updated} + ${SLACK_COMMAND_POST_STALE_MS} end`;
|
|
if (kind === "slack_session_sync") return sql<number>`case
|
|
when ${chatEndpoints.status} in ('paused', 'attention') then null
|
|
when ${chatActions.status} = 'received' then ${retry}
|
|
when ${chatActions.status} = 'processing' then ${updated} + ${PROVIDER_EFFECT_STALE_MS} end`;
|
|
return sql<number>`case
|
|
when ${chatActions.status} = 'received' then 0
|
|
when ${chatActions.status} = 'processing' then ${updated} + ${PROVIDER_EFFECT_STALE_MS}
|
|
when ${chatActions.status} = 'failed' and ${chatActions.result}->>'retryable' = 'true' then ${retry} end`;
|
|
}
|
|
|
|
function pendingChatAction(kind: ScheduledChatAction) {
|
|
// Filter history before the endpoint join and aggregate. Failed actions
|
|
// only remain live when the saved result permits another attempt.
|
|
const status = kind === "slack_board_message"
|
|
? eq(chatActions.status, "received")
|
|
: kind === "slash_task_start"
|
|
? inArray(chatActions.status, ["queued", "provider_confirmed", "admitting", "resolving", "validating"])
|
|
: kind === "slack_session_sync"
|
|
? inArray(chatActions.status, ["received", "processing"])
|
|
: or(inArray(chatActions.status, ["received", "processing"]),
|
|
and(eq(chatActions.status, "failed"), sql`${chatActions.result}->>'retryable' = 'true'`));
|
|
return and(eq(chatActions.kind, kind), status);
|
|
}
|
|
|
|
export function dueChatAction(kind: ScheduledChatAction, now = Date.now()) {
|
|
return and(pendingChatAction(kind), lte(chatActionDeadline(kind), now));
|
|
}
|
|
|
|
export async function nextChatActionAt(db: Db, kind: ScheduledChatAction): Promise<number | null> {
|
|
const [row] = await db.select({ at: sql<string | null>`min(${chatActionDeadline(kind)})` })
|
|
.from(chatActions)
|
|
.leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId)))
|
|
.where(pendingChatAction(kind));
|
|
return row?.at == null ? null : Number(row.at);
|
|
}
|
|
|
|
type ChatActionService = {
|
|
processPendingProviderEffects(): Promise<unknown>;
|
|
processPendingGitHubWebhookIngress(): Promise<unknown>;
|
|
processPendingSlackSessionSyncs(): Promise<unknown>;
|
|
processPendingSlackBoardMessages(): Promise<unknown>;
|
|
processPendingSlackTaskStarts(): Promise<unknown>;
|
|
processPendingReceiptReactions(): Promise<unknown>;
|
|
processPendingSlackSessionStops(): Promise<unknown>;
|
|
processPendingVerificationMessages(): Promise<unknown>;
|
|
nextChatActionAt(kind: ScheduledChatAction): Promise<number | null>;
|
|
nextVerificationMessageAt(): Promise<number | null>;
|
|
};
|
|
|
|
export function registerChatActionWork(
|
|
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
|
service: ChatActionService,
|
|
canRun: () => boolean,
|
|
) {
|
|
const actions = [
|
|
{ kind: "provider_effect", run: service.processPendingProviderEffects },
|
|
{ kind: "github_webhook_ingress", run: service.processPendingGitHubWebhookIngress },
|
|
{ kind: "slack_session_sync", run: service.processPendingSlackSessionSyncs },
|
|
{ kind: "slack_board_message", run: service.processPendingSlackBoardMessages },
|
|
{ kind: "slash_task_start", run: service.processPendingSlackTaskStarts },
|
|
{ kind: "receipt_reaction", run: service.processPendingReceiptReactions },
|
|
{ kind: "slack_session_stop", run: service.processPendingSlackSessionStops },
|
|
] as const;
|
|
const workers = actions.map(({ kind, run }) => registerChatQueueWork(coordinator, {
|
|
queue: CHAT_ACTION_QUEUES[kind], run, next: () => service.nextChatActionAt(kind),
|
|
}, canRun));
|
|
workers.push(registerChatQueueWork(coordinator, {
|
|
queue: DELIVERY_QUEUES.chatVerification,
|
|
run: service.processPendingVerificationMessages, next: service.nextVerificationMessageAt,
|
|
}, canRun));
|
|
return { ready: Promise.all(workers.map(worker => worker.ready)) };
|
|
}
|