Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions apps/cursor/src/lib/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
),
);
}),
);
Expand Down
29 changes: 29 additions & 0 deletions apps/cursor/src/lib/plugins/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginModerationState, "scan_status" | "updated_at">,
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
Expand Down