perf(chat): stop polling failed run retries (#15871)

Stop one-second scans of failed chat-run retries. Wake the existing retry queue after transaction commits and at saved retry or stale-claim deadlines. Preserve claim fences, authorization, recovery, and idempotent agent admission.

Resolve conflicts with the current chat-service extraction by applying the retry signals and shared deadline predicate in the failed-run retry module.

Verified with pnpm typecheck, pnpm build, all 1,328 chat and coordinator regression tests, and current-head CI. Greptile reviewed the final head at 5/5 with no unresolved threads.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-12 19:49:47 -07:00
1 parent 7d2bb5aaab
commit e168512f7b
9 files changed
+157 -56

No files matched your search

@@ -444,8 +444,13 @@ Longer polling intervals alone do not complete an item.
running sessions and pauses without rescheduling until an endpoint change.
Tests exercise real producers with deferred fast paths disabled, retry and
startup recovery, stale-send quarantine, and zero scans after settlement.
Remaining **1s** maintenance: provider runtimes, Telegram maintenance, failed
chat-run retries, run milestone/wake-notice projection, GitHub operation
Failed chat-run retries now also use commit wakes and durable deadlines from
existing action rows. Issued retries honor `retryAt`; processing claims wait
for both stale-claim expiry and any saved retry time. Startup recovers unfinished
requests. Claim and settlement writes signal in their own short transactions,
with agent wakeup outside those transactions. Empty and terminal queues disarm.
Remaining **1s** maintenance: provider runtimes, Telegram maintenance,
run milestone/wake-notice projection, GitHub operation
recovery, and Teams protocol maintenance. GitHub pre-ingress recovery
must still inspect provider history: a lost callback has no local queue row.
This slice does **not** yet make the whole chat subsystem quiet. Independent
@@ -110,6 +110,7 @@ describe("createChatReconciliationCoordinator", () => {
const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("),
service.indexOf("async function processPendingDeliveries("));
expect(actionMaintenance).not.toContain("processFailedChatRunRetries(");
expect(actionMaintenance).not.toContain("processPendingProviderEffects(");
expect(actionMaintenance).not.toContain("processPendingGitHubWebhookIngress(");
});
@@ -19,6 +19,7 @@ function setup() {
const coordinator = createDeliveryWorkCoordinator({ owner, canRun: enabled, canReconcile: enabled, onError });
coordinators.push(coordinator);
const service = {
processFailedChatRunRetries: vi.fn(async () => {}),
processPendingProviderEffects: vi.fn(async () => {}),
processPendingGitHubWebhookIngress: vi.fn(async () => {}),
processPendingSlackSessionSyncs: vi.fn(async () => {}),
@@ -30,7 +31,7 @@ function setup() {
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,
const runs = [service.processFailedChatRunRetries, 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) };
}
@@ -40,7 +41,7 @@ describe("chat action scheduling", () => {
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.nextChatActionAt).toHaveBeenCalledTimes(8);
expect(s.service.nextVerificationMessageAt).toHaveBeenCalledTimes(1);
expect(s.coordinator.nextWakeAt()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
@@ -1,3 +1,4 @@
import { idleWorkSnapshot } from "../services/task-admission.js";
import * as activityLogModule from "../services/activity-log.js";
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js";
@@ -33026,6 +33027,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
await set("delivery_unknown");
expect(await f.service.nextChatActionAt(kind)).toBeNull();
expect(await due()).toBe(false);
} else if (kind === "failed_run_retry") {
expect(await due()).toBe(false);
await set("issued");
expect(await due()).toBe(true);
await set("issued", { retryAt: later.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
expect(await due()).toBe(false);
await set("processing");
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
expect(await due()).toBe(false);
// Both the claim lease and retry time must expire before replay.
await set("processing", { retryAt: later.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(later.getTime());
await set("processing", { retryAt: at.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBe(at.getTime() + 60_000);
for (const status of ["failed", "cancelled", "processed"]) {
await set(status, { retryable: true, retryAt: at.toISOString() });
expect(await f.service.nextChatActionAt(kind)).toBeNull();
expect(await due()).toBe(false);
}
} else if (kind === "slack_session_sync") {
expect(await due()).toBe(true);
await set("received", { retryAt: later.toISOString() });
@@ -59779,6 +59800,81 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
);
it.each(["commit", "retry", "startup", "stale_claim", "rollback"] as const)(
"drains failed chat retries from durable work and becomes idle: %s",
async (mode) => {
const context = await failedChatRetryFixture("telegram");
const onError = vi.fn();
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError });
const input = { companyId: context.fixture.companyId, issueId: context.issue.id,
agentId: context.fixture.assignedAgentId, failedRunId: context.runId, initiatedByUserId: "owner-user" };
const stage = () => db.transaction(tx => context.service.prepareFailedChatRunRetry(tx, input));
context.wakeup.mockClear();
try {
let actionId: string;
if (mode === "startup" || mode === "stale_claim") {
({ actionId } = await stage());
if (mode === "stale_claim") await db.update(chatActions).set({ status: "processing",
updatedAt: new Date(Date.now() - 120_000) }).where(eq(chatActions.id, actionId));
await registerChatActionWork(coordinator, context.service, () => true).ready;
} else {
await registerChatActionWork(coordinator, context.service, () => true).ready;
if (mode === "rollback") {
await expect(db.transaction(async tx => {
await context.service.prepareFailedChatRunRetry(tx, input);
throw new Error("rollback retry intent");
})).rejects.toThrow("rollback retry intent");
await vi.waitFor(() => {
expect(coordinator.nextWakeAt()).toBeNull();
expect(idleWorkSnapshot().active).toBe(0);
}, { timeout: 10_000 });
expect(context.wakeup).not.toHaveBeenCalled();
}
if (mode === "retry") {
let failedAt = 0;
context.wakeup.mockImplementationOnce(async () => {
failedAt = Date.now();
throw new Error("temporary admission outage");
}).mockImplementationOnce(async () => {
expect(Date.now() - failedAt).toBeGreaterThanOrEqual(250);
return { accepted: true };
});
}
({ actionId } = await db.transaction(async tx => {
const staged = await tx.transaction(nested => context.service.prepareFailedChatRunRetry(nested, input));
// A nested savepoint must not wake the worker before the outer commit.
await new Promise(resolve => setTimeout(resolve, 25));
expect(context.wakeup).not.toHaveBeenCalled();
return staged;
}));
}
await vi.waitFor(async () => {
const [action] = await db.select().from(chatActions).where(eq(chatActions.id, actionId));
expect(action?.status).toBe("processed");
expect(action?.result.attemptCount).toBe(mode === "retry" ? 2 : 1);
expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
expect(context.wakeup).toHaveBeenCalledTimes(mode === "retry" ? 2 : 1);
const receipts = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, actionId));
expect(receipts).toHaveLength(1);
expect(receipts[0]?.payload).toMatchObject({ retryOfRunId: context.runId,
wakeCommentIds: [context.action.payload.commentId] });
// A running worker has no scheduled deadline. Wait for its final scan
// and any commit-driven follow-up before measuring the quiet period.
await vi.waitFor(() => {
expect(coordinator.nextWakeAt()).toBeNull();
expect(idleWorkSnapshot().active).toBe(0);
}, { timeout: 10_000 });
const scans = vi.spyOn(db, "select");
try {
await new Promise(resolve => setTimeout(resolve, 1200));
expect(scans).not.toHaveBeenCalled();
} finally { scans.mockRestore(); }
expect(onError).not.toHaveBeenCalled();
} finally { await coordinator.stop(); await context.service.shutdown(); }
},
);
it.each(["slack", "telegram"] as const)(
"retries the exact failed %s chat input with a new idempotent admission",
async (provider) => {
+16 -8
View File
@@ -8,6 +8,7 @@ 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 FAILED_RUN_RETRY_STALE_MS = 60_000;
export const PROVIDER_EFFECT_STALE_MS = 60_000;
/** The dispatch predicate and scheduler use the same eligibility and deadlines.
@@ -15,6 +16,9 @@ export const PROVIDER_EFFECT_STALE_MS = 60_000;
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 === "failed_run_retry") return sql<number>`case
when ${chatActions.status} = 'issued' then ${retry}
when ${chatActions.status} = 'processing' then greatest(${updated} + ${FAILED_RUN_RETRY_STALE_MS}, ${retry}) end`;
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
@@ -36,14 +40,16 @@ export function chatActionDeadline(kind: ScheduledChatAction) {
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'`));
const status = kind === "failed_run_retry"
? inArray(chatActions.status, ["issued", "processing"])
: 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);
}
@@ -60,6 +66,7 @@ export async function nextChatActionAt(db: Db, kind: ScheduledChatAction): Promi
}
type ChatActionService = {
processFailedChatRunRetries(): Promise<unknown>;
processPendingProviderEffects(): Promise<unknown>;
processPendingGitHubWebhookIngress(): Promise<unknown>;
processPendingSlackSessionSyncs(): Promise<unknown>;
@@ -78,6 +85,7 @@ export function registerChatActionWork(
canRun: () => boolean,
) {
const actions = [
{ kind: "failed_run_retry", run: service.processFailedChatRunRetries },
{ kind: "provider_effect", run: service.processPendingProviderEffects },
{ kind: "github_webhook_ingress", run: service.processPendingGitHubWebhookIngress },
{ kind: "slack_session_sync", run: service.processPendingSlackSessionSyncs },
+2 -3
View File
@@ -5729,7 +5729,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
lifecycleRuntimeFence,
isShuttingDown: () => shuttingDown,
wakeup: (...args) => options.heartbeat.wakeup(...args),
staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
});
const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds });
async function authorizeRetainedChatSourcePublication(
@@ -15429,7 +15428,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
async function processPendingChatMaintenance(limit = 25) {
return Promise.allSettled([
processPendingTelegramMaintenance(limit),
processFailedChatRunRetries(limit),
]);
}
@@ -15440,7 +15438,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
// so a crashed processing claim is quarantined before the delivery worker
// could otherwise replay it.
const actionRecovery = onlyDeliveryId || !maintenance ? null : Promise.allSettled([
processPendingChatMaintenance(limit), processPendingGitHubWebhookIngress(limit),
processPendingChatMaintenance(limit), processFailedChatRunRetries(limit),
processPendingGitHubWebhookIngress(limit),
processPendingProviderEffects(limit), processPendingSlackBoardMessages(limit),
processPendingSlackTaskStarts(limit), processPendingReceiptReactions(limit),
processPendingSlackSessionStops(limit), slackRegistration.processPendingVerificationMessages(limit),
@@ -1,5 +1,6 @@
import { continuingTeamsFilePublication, type ContinuingTeamsFileTransfer } from "./microsoft-teams/file-transfer-source.js";
import { notifyChatPublicationWork } from "../chat-work-notifications.js";
import { notifyChatActionWork, notifyChatPublicationWork } from "../chat-work-notifications.js";
import { FAILED_RUN_RETRY_STALE_MS, dueChatAction } from "../chat-action-work.js";
import {
createHash,
@@ -13,7 +14,6 @@ import {
isNotNull,
isNull,
like,
lte,
ne,
notInArray,
or,
@@ -104,7 +104,6 @@ type ChatFailedRunRetryOptions = {
lifecycleRuntimeFence: (delivery: DeliveryRow) => RetryRuntimeFence | null;
isShuttingDown: () => boolean;
wakeup: IssueAssignmentWakeupDeps["wakeup"];
staleProcessingMs: number;
};
export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOptions) {
@@ -115,7 +114,6 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
lifecycleRuntimeFence,
isShuttingDown,
wakeup,
staleProcessingMs: DELIVERY_PROCESSING_STALE_MS,
} = options;
@@ -1265,6 +1263,7 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
.limit(1);
if (busy || !input.initiatedByUserId) throw failedChatRetryDenied();
const now = new Date();
await notifyChatActionWork(tx, "failed_run_retry");
const [intent] = await tx
.insert(chatActions)
.values({
@@ -1508,36 +1507,42 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
!["issued", "processing"].includes(candidate.status) ||
(candidate.status === "processing" &&
candidate.updatedAt.getTime() >
now.getTime() - DELIVERY_PROCESSING_STALE_MS) ||
now.getTime() - FAILED_RUN_RETRY_STALE_MS) ||
(typeof candidate.result?.retryAt === "string" &&
Date.parse(candidate.result.retryAt) > now.getTime())
)
return failedChatRetryReceipt(candidate);
const [claimed] = await db
.update(chatActions)
.set({ status: "processing", updatedAt: now })
.where(
and(
eq(chatActions.id, candidate.id),
eq(chatActions.status, candidate.status),
eq(chatActions.updatedAt, candidate.updatedAt),
),
)
.returning();
if (!claimed) return failedChatRetryReceipt(candidate);
const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
const settle = async (status: string, result: Record<string, unknown>) => {
const [current] = await db
const [claimed] = await db.transaction(async (tx) => {
await notifyChatActionWork(tx, "failed_run_retry");
return tx
.update(chatActions)
.set({ status, result, updatedAt: new Date() })
.set({ status: "processing", updatedAt: now })
.where(
and(
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
eq(chatActions.updatedAt, now),
eq(chatActions.id, candidate.id),
eq(chatActions.status, candidate.status),
eq(chatActions.updatedAt, candidate.updatedAt),
),
)
.returning();
});
if (!claimed) return failedChatRetryReceipt(candidate);
const attemptCount = Number(candidate.result?.attemptCount ?? 0) + 1;
const settle = async (status: string, result: Record<string, unknown>) => {
const [current] = await db.transaction(async (tx) => {
await notifyChatActionWork(tx, "failed_run_retry");
return tx
.update(chatActions)
.set({ status, result, updatedAt: new Date() })
.where(
and(
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
eq(chatActions.updatedAt, now),
),
)
.returning();
});
return failedChatRetryReceipt(current ?? claimed);
};
try {
@@ -1669,26 +1674,10 @@ export function createChatFailedRunRetry(db: Db, options: ChatFailedRunRetryOpti
async function processFailedChatRunRetries(limit = 20) {
if (isShuttingDown()) return 0;
const now = new Date();
const candidates = await db
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.kind, "failed_run_retry"),
or(
eq(chatActions.status, "issued"),
and(
eq(chatActions.status, "processing"),
lte(
chatActions.updatedAt,
new Date(now.getTime() - DELIVERY_PROCESSING_STALE_MS),
),
),
),
sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${now.toISOString()}::timestamptz)`,
),
)
.where(dueChatAction("failed_run_retry"))
.orderBy(asc(chatActions.createdAt), asc(chatActions.id))
.limit(Math.max(1, Math.min(50, limit)));
for (const candidate of candidates)
@@ -13,6 +13,7 @@ export async function notifyChatPublicationWork(tx: object): Promise<void> {
export const CHAT_ACTION_QUEUES = {
provider_effect: DELIVERY_QUEUES.chatProviderEffects,
github_webhook_ingress: DELIVERY_QUEUES.chatGitHubIngress,
failed_run_retry: DELIVERY_QUEUES.chatFailedRunRetries,
slack_session_sync: DELIVERY_QUEUES.chatSessionSyncs,
slack_board_message: DELIVERY_QUEUES.chatBoardMessages,
slash_task_start: DELIVERY_QUEUES.chatTaskStarts,
@@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = {
chatSessionStops: "chat-session-stops",
chatProviderEffects: "chat-provider-effects",
chatGitHubIngress: "chat-github-ingress",
chatFailedRunRetries: "chat-failed-run-retries",
chatSessionSyncs: "chat-session-syncs",
chatVerification: "chat-verification",
chatInbound: "chat-inbound",