diff --git a/convex/securityScan.test.ts b/convex/securityScan.test.ts index 5fcc2e806..5dd8ecfb0 100644 --- a/convex/securityScan.test.ts +++ b/convex/securityScan.test.ts @@ -4223,6 +4223,33 @@ describe("securityScan", () => { expect(claimed.map((job) => job._id)).toEqual(["securityScanJobs:publish"]); }); + it.each([1, 4, 16])( + "reads only the needed native queue rows for a %i-job claim with GitHub rollout off", + async (limit) => { + vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); + const jobs = Array.from({ length: 512 }, (_, index) => + makeScanJob({ + _id: `securityScanJobs:bulk-${index}`, + source: "bulk-rescan", + createdAt: index + 1, + nextRunAt: index + 1, + }), + ); + const { ctx } = makeClaimCtx(jobs); + + const claimed = await claimQueuedJobsInternalHandler(ctx, { + workerId: "shared-worker", + lane: "shared", + limit, + leaseMs: 60_000, + }); + + expect(claimed.map((job) => job._id)).toEqual(jobs.slice(0, limit).map((job) => job._id)); + const pages = await Promise.all(ctx.runQuery.mock.results.map((result) => result.value)); + expect(pages.reduce((total, page) => total + page.page.length, 0)).toBe(limit); + }, + ); + it("skips queued generic GitHub scans while still claiming NVIDIA scans when rollout is off", async () => { vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off"); const genericJobs = Array.from({ length: 513 }, (_, index) => @@ -4290,6 +4317,11 @@ describe("securityScan", () => { "securityScanJobs:nvidia", "skillScanRequests:nvidia", ]); + // Sparse legacy jobs must not turn a one-job claim into hundreds of queries. + const publishPages = ctx.runQuery.mock.calls.filter( + ([, args]) => (args as { source: string }).source === "publish", + ); + expect(publishPages.length).toBeLessThanOrEqual(3); }); it("lets the catalog lane claim only the lowest-priority catalog source", async () => { diff --git a/convex/securityScan.ts b/convex/securityScan.ts index cc17b6ed2..a502379f2 100644 --- a/convex/securityScan.ts +++ b/convex/securityScan.ts @@ -3105,6 +3105,7 @@ export const claimQueuedJobsInternal = internalMutation({ } const eligible: Doc<"securityScanJobs">[] = []; let cursor: string | null = null; + let pageSize = Math.min(takeLimit, MAX_CODEX_SCAN_CLAIM_LIMIT); do { const page: ReadySourceJobsForClaimPage = await runQueryRef( ctx, @@ -3113,14 +3114,18 @@ export const claimQueuedJobsInternal = internalMutation({ source, now, cursor, - numItems: githubSkillSyncEnabled - ? Math.min(takeLimit, MAX_CODEX_SCAN_CLAIM_LIMIT) - : MAX_CODEX_SCAN_CLAIM_LIMIT, + numItems: pageSize, excludeGitHubSkillSync: !githubSkillSyncEnabled, }, ); for (const job of page.page) { - if (await isJobRolloutClaimable(job)) eligible.push(job); + if (await isJobRolloutClaimable(job)) { + eligible.push(job); + } else { + // Native claims should not read the whole queue. Expand only after + // blocked legacy GitHub jobs require scanning past the first page. + pageSize = MAX_CODEX_SCAN_CLAIM_LIMIT; + } if (eligible.length >= takeLimit) return eligible; } cursor = page.isDone ? null : page.continueCursor; diff --git a/specs/security-moderation.md b/specs/security-moderation.md index 69bae4685..3354c6baa 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -501,6 +501,10 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic backend claim path must cap only a single worker claim size and must not impose a global active-scan ceiling; horizontal capacity is controlled by worker dispatch count, worker batch limit, provider quotas, and cost monitoring. +- Normal scan claims read only enough ready queue rows to fill the worker's + remaining capacity. Broader pagination is reserved for skipping blocked legacy + GitHub jobs or the catalog lane's bounded admission window; disabling a rollout + must not make every native one-job claim read hundreds of unrelated jobs. - The Skill Card verification envelope exposes ClawScan as the top-level `security` verdict for install automation, with deterministic and third-party scanner evidence grouped under `security.signals`. Clients should key install