From 47e52cf2244deee56ed1f462d208e4c77edfab6c Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Sun, 25 Oct 2026 20:50:56 +0300 Subject: [PATCH] fix(moderation): refuse a re-scan while one is queued or running, from every entry point --- apps/cursor/src/lib/mcp/tools.ts | 22 ++++++----------- apps/cursor/src/lib/plugins/moderation.ts | 29 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/cursor/src/lib/mcp/tools.ts b/apps/cursor/src/lib/mcp/tools.ts index 8fad1662..0f026654 100644 --- a/apps/cursor/src/lib/mcp/tools.ts +++ b/apps/cursor/src/lib/mcp/tools.ts @@ -39,9 +39,6 @@ const UNTRUSTED_NOTE = const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -// Matches the stuck-scan threshold in getStuckScans(): older pending/scanning -// rows are treated as stuck and may be re-scanned. -const SCAN_IN_PROGRESS_MS = 15 * 60 * 1000; const MAX_COMPONENT_CONTENT_CHARS = 4000; const MAX_SUMMARY_CHARS = 500; @@ -534,20 +531,15 @@ export function registerModerationTools(server: McpServer) { annotations: { ...write, idempotentHint: false }, }, adminTool(async ({ plugin }, admin) => { - const state = await getPluginModerationState( - await resolvePluginId(plugin), - ); - const inProgress = - (state.scan_status === "pending" || state.scan_status === "scanning") && - Date.now() - new Date(state.updated_at).getTime() < SCAN_IN_PROGRESS_MS; - if (inProgress) { - throw new ToolError( - `A scan for "${state.slug}" is already ${state.scan_status}. Check again in a few minutes.`, - ); - } + // rescanPlugin refuses with a ModerationError while a scan is queued or + // running (same check the admin UI buttons go through); adminTool turns + // that into the error text. return changed( "Re-scan queued", - await rescanPlugin(await beginWrite(admin), state.id), + await rescanPlugin( + await beginWrite(admin), + await resolvePluginId(plugin), + ), ); }), ); diff --git a/apps/cursor/src/lib/plugins/moderation.ts b/apps/cursor/src/lib/plugins/moderation.ts index 920ac324..644aef6e 100644 --- a/apps/cursor/src/lib/plugins/moderation.ts +++ b/apps/cursor/src/lib/plugins/moderation.ts @@ -203,7 +203,36 @@ export function blockPlugin( ); } +/** + * A pending/scanning row younger than this still has a live queue message or + * a running worker behind it. Matches the stuck-scan threshold shared by + * getStuckScans() and the recover-stuck-scans cron: an older row is treated + * as stuck and may be re-scanned. + */ +export const SCAN_IN_PROGRESS_MS = 15 * 60 * 1000; + +export function isScanInProgress( + state: Pick, + now = Date.now(), +): boolean { + if (state.scan_status !== "pending" && state.scan_status !== "scanning") { + return false; + } + return now - new Date(state.updated_at).getTime() < SCAN_IN_PROGRESS_MS; +} + export async function rescanPlugin(ctx: ModerationContext, pluginId: string) { + // Every re-scan entry point (admin UI buttons, MCP tool) lands here, so the + // in-progress check lives here too. Without it a second click while the + // first scan is queued or running enqueues a second message for the same + // plugin: two billable agent runs whose verdicts race each other. + const state = await getPluginModerationState(pluginId); + if (isScanInProgress(state)) { + throw new ModerationError( + `A scan for "${state.slug}" is already ${state.scan_status}. Check again in a few minutes.`, + ); + } + // The status reset must reach cached readers (detail banner, leaderboard) // before enqueueing, so a queue failure can't leave cached views showing a // stale status while the row is already pending. The drain route only