From f868b6cd620056f0d67c8d743dbd5d732e00ddb1 Mon Sep 17 00:00:00 2001 From: stainlu Date: Wed, 6 May 2026 13:02:21 +0800 Subject: [PATCH 1/2] perf: cache comment router issue comments --- CHANGELOG.md | 2 ++ src/repair/comment-router-core.ts | 16 ++++++++++++++++ src/repair/comment-router.ts | 25 ++++++++----------------- test/repair/comment-router-core.test.ts | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4e66549d..cf5a651077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ checkpoint, and status-only commits are intentionally omitted. immediate duplicate capacity probe in the dispatch loop. - Cached comment-router open-label issue lookups per run so repair-loop comment discovery and command synthesis do not repeat identical GitHub searches. +- Cached comment-router issue comment lookups per run so targeted command routing + and replay/status checks do not repeat identical comment pagination. - Retried Codex edit workers after TPM/rate-limit exits and collapsed JSONL failure transcripts into concise repair status reasons. - Added deterministic merged closing-PR provenance to issue close reports and public close comments when GitHub exposes a high-confidence closing PR. diff --git a/src/repair/comment-router-core.ts b/src/repair/comment-router-core.ts index 2fac1ca548..52757e8b16 100644 --- a/src/repair/comment-router-core.ts +++ b/src/repair/comment-router-core.ts @@ -116,6 +116,22 @@ export function createCachedLabelNumberLookup(fetchNumbers: (label: string) => J }; } +export function createCachedIssueCommentsLookup( + fetchComments: (number: number) => T[], +) { + const cache = new Map(); + return (number: JsonValue): T[] => { + const key = Number(number); + if (!Number.isInteger(key) || key <= 0) return []; + const cached = cache.get(key); + if (cached) return [...cached]; + const comments = fetchComments(key); + const safeComments = Array.isArray(comments) ? comments : []; + cache.set(key, safeComments); + return [...safeComments]; + }; +} + function uniquePositiveIntegers(values: JsonValue): number[] { if (!Array.isArray(values)) return []; return [ diff --git a/src/repair/comment-router.ts b/src/repair/comment-router.ts index 8d964f11cf..b5be1ca6b0 100644 --- a/src/repair/comment-router.ts +++ b/src/repair/comment-router.ts @@ -33,6 +33,7 @@ import { automergeTransientWaitConfig, buildAutomergeMergeArgs, commandHasAction, + createCachedIssueCommentsLookup, createCachedLabelNumberLookup, hasCommandResponseMarker, commandStatusMarker, @@ -77,7 +78,6 @@ import { ghJsonWithRetry as ghJson, ghJsonWithRetryAsync as ghJsonAsync, ghPagedWithRetry as ghPaged, - ghPagedWithRetryAsync as ghPagedAsync, ghSpawn, ghTextWithRetry as ghText, } from "./github-cli.js"; @@ -132,7 +132,9 @@ let repairLoopControlEntriesCache: LooseRecord[] | null = null; const collaboratorPermissionCache = new Map(); const activeRepairRunsByPrefix = new Map(); const liveTargetCache = new Map(); -const issueCommentsCache = new Map(); +const cachedIssueComments = createCachedIssueCommentsLookup((number) => + ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`), +); const openIssueNumbersByLabel = createCachedLabelNumberLookup((label) => ghPaged( `repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`, @@ -306,7 +308,7 @@ async function prehydrateCommandLookups(commands: LooseRecord[]) { liveTargetCache.set(number, await fetchLiveTargetAsync(number)); }), mapLimit(issueNumbers, lookupConcurrency, async (number) => { - issueCommentsCache.set(number, await fetchIssueCommentsAsync(number)); + cachedIssueComments(number); }), ]); } @@ -2317,10 +2319,7 @@ function linesFromMarkdownSection(section: JsonValue): string[] { } function issueCommentsFor(number: JsonValue): JsonValue[] { - return ( - issueCommentsCache.get(Number(number)) ?? - ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`) - ); + return cachedIssueComments(number); } function listRepairLoopReviewComments() { @@ -2586,9 +2585,7 @@ function hasExistingResponse( intent: JsonValue, headSha: JsonValue, ) { - const comments = - issueCommentsCache.get(Number(number)) ?? - ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`); + const comments = cachedIssueComments(number); return comments.some((comment: JsonValue) => { const body = String(comment.body ?? ""); if (!hasCommandResponseMarker(body, { commentId, intent, headSha, matchAnyHead: true })) { @@ -2611,9 +2608,7 @@ function hasExistingResponse( function hasExistingModeStatusResponse(number: JsonValue, intent: JsonValue) { const markerPrefix = commandStatusMarkerPrefix({ issue_number: number, intent }); - const comments = - issueCommentsCache.get(Number(number)) ?? - ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`); + const comments = cachedIssueComments(number); return comments.some((comment: JsonValue) => { if (!isTrustedStatusComment(comment)) return false; const body = String(comment.body ?? ""); @@ -2621,10 +2616,6 @@ function hasExistingModeStatusResponse(number: JsonValue, intent: JsonValue) { }); } -async function fetchIssueCommentsAsync(number: JsonValue) { - return ghPagedAsync(`repos/${targetRepo}/issues/${number}/comments?per_page=100`); -} - function postComment(command: LooseRecord, body: string) { const existing = findExistingCommandStatusComment(command); const nextBody = usesSharedAutomergeStatus(command) diff --git a/test/repair/comment-router-core.test.ts b/test/repair/comment-router-core.test.ts index 325c1218e2..f9382e915b 100644 --- a/test/repair/comment-router-core.test.ts +++ b/test/repair/comment-router-core.test.ts @@ -18,6 +18,7 @@ import { automergeTransientWaitConfig, buildAutomergeMergeArgs, commandHasAction, + createCachedIssueCommentsLookup, commandResponseMarker, commandResponseMarkerPrefix, commandStatusMarkerPrefix, @@ -205,6 +206,23 @@ test("cached label number lookup fetches each label once and returns stable copi assert.deepEqual(calls, ["clawsweeper:autofix", "clawsweeper:automerge"]); }); +test("cached issue comments lookup fetches each issue once and returns stable copies", () => { + const calls: number[] = []; + const lookup = createCachedIssueCommentsLookup((number) => { + calls.push(number); + return [{ id: number * 10 }, { id: number * 10 + 1 }]; + }); + + const first = lookup(12); + first.push({ id: 999 }); + + assert.deepEqual(first, [{ id: 120 }, { id: 121 }, { id: 999 }]); + assert.deepEqual(lookup("12"), [{ id: 120 }, { id: 121 }]); + assert.deepEqual(lookup(13), [{ id: 130 }, { id: 131 }]); + assert.deepEqual(lookup(0), []); + assert.deepEqual(calls, [12, 13]); +}); + test("autoclose reason parser preserves maintainer wording", () => { assert.equal( autocloseReasonFromCommand("autoclose We don't want this feature"), From e2853b3043bc1cee2d7ddecdea592ca528ac4994 Mon Sep 17 00:00:00 2001 From: stainlu Date: Sat, 9 May 2026 01:21:28 +0800 Subject: [PATCH 2/2] perf: keep comment prehydration async --- src/repair/comment-router-core.ts | 34 +++++++++++++++--- src/repair/comment-router.ts | 14 ++++++-- test/repair/comment-router-core.test.ts | 47 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/repair/comment-router-core.ts b/src/repair/comment-router-core.ts index 52757e8b16..97161ffd63 100644 --- a/src/repair/comment-router-core.ts +++ b/src/repair/comment-router-core.ts @@ -118,17 +118,43 @@ export function createCachedLabelNumberLookup(fetchNumbers: (label: string) => J export function createCachedIssueCommentsLookup( fetchComments: (number: number) => T[], + cache = new Map(), ) { - const cache = new Map(); return (number: JsonValue): T[] => { const key = Number(number); if (!Number.isInteger(key) || key <= 0) return []; const cached = cache.get(key); if (cached) return [...cached]; const comments = fetchComments(key); - const safeComments = Array.isArray(comments) ? comments : []; - cache.set(key, safeComments); - return [...safeComments]; + if (!Array.isArray(comments)) return []; + cache.set(key, comments); + return [...comments]; + }; +} + +export function createCachedIssueCommentsLookupAsync( + fetchComments: (number: number) => Promise, + cache = new Map(), +) { + const inFlight = new Map>(); + return async (number: JsonValue): Promise => { + const key = Number(number); + if (!Number.isInteger(key) || key <= 0) return []; + const cached = cache.get(key); + if (cached) return [...cached]; + const pending = inFlight.get(key); + if (pending) return [...(await pending)]; + const next = fetchComments(key) + .then((comments) => { + if (!Array.isArray(comments)) return []; + cache.set(key, comments); + return comments; + }) + .finally(() => { + inFlight.delete(key); + }); + inFlight.set(key, next); + return [...(await next)]; }; } diff --git a/src/repair/comment-router.ts b/src/repair/comment-router.ts index b5be1ca6b0..50fd77657e 100644 --- a/src/repair/comment-router.ts +++ b/src/repair/comment-router.ts @@ -34,6 +34,7 @@ import { buildAutomergeMergeArgs, commandHasAction, createCachedIssueCommentsLookup, + createCachedIssueCommentsLookupAsync, createCachedLabelNumberLookup, hasCommandResponseMarker, commandStatusMarker, @@ -78,6 +79,7 @@ import { ghJsonWithRetry as ghJson, ghJsonWithRetryAsync as ghJsonAsync, ghPagedWithRetry as ghPaged, + ghPagedWithRetryAsync as ghPagedAsync, ghSpawn, ghTextWithRetry as ghText, } from "./github-cli.js"; @@ -132,8 +134,14 @@ let repairLoopControlEntriesCache: LooseRecord[] | null = null; const collaboratorPermissionCache = new Map(); const activeRepairRunsByPrefix = new Map(); const liveTargetCache = new Map(); -const cachedIssueComments = createCachedIssueCommentsLookup((number) => - ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`), +const issueCommentsCache = new Map(); +const cachedIssueComments = createCachedIssueCommentsLookup( + (number) => ghPaged(`repos/${targetRepo}/issues/${number}/comments?per_page=100`), + issueCommentsCache, +); +const cachedIssueCommentsAsync = createCachedIssueCommentsLookupAsync( + (number) => ghPagedAsync(`repos/${targetRepo}/issues/${number}/comments?per_page=100`), + issueCommentsCache, ); const openIssueNumbersByLabel = createCachedLabelNumberLookup((label) => ghPaged( @@ -308,7 +316,7 @@ async function prehydrateCommandLookups(commands: LooseRecord[]) { liveTargetCache.set(number, await fetchLiveTargetAsync(number)); }), mapLimit(issueNumbers, lookupConcurrency, async (number) => { - cachedIssueComments(number); + await cachedIssueCommentsAsync(number); }), ]); } diff --git a/test/repair/comment-router-core.test.ts b/test/repair/comment-router-core.test.ts index f9382e915b..f04d0bb666 100644 --- a/test/repair/comment-router-core.test.ts +++ b/test/repair/comment-router-core.test.ts @@ -19,6 +19,7 @@ import { buildAutomergeMergeArgs, commandHasAction, createCachedIssueCommentsLookup, + createCachedIssueCommentsLookupAsync, commandResponseMarker, commandResponseMarkerPrefix, commandStatusMarkerPrefix, @@ -223,6 +224,52 @@ test("cached issue comments lookup fetches each issue once and returns stable co assert.deepEqual(calls, [12, 13]); }); +test("cached async issue comments lookup shares cache and in-flight fetches", async () => { + const cache = new Map(); + const calls: number[] = []; + const asyncLookup = createCachedIssueCommentsLookupAsync(async (number) => { + calls.push(number); + await new Promise((resolve) => setTimeout(resolve, 5)); + return [{ id: number * 10 }]; + }, cache); + const syncLookup = createCachedIssueCommentsLookup((number) => { + calls.push(number); + return [{ id: number * 100 }]; + }, cache); + + const [first, second] = await Promise.all([asyncLookup(12), asyncLookup("12")]); + first.push({ id: 999 }); + + assert.deepEqual(first, [{ id: 120 }, { id: 999 }]); + assert.deepEqual(second, [{ id: 120 }]); + assert.deepEqual(syncLookup(12), [{ id: 120 }]); + assert.deepEqual(await asyncLookup(0), []); + assert.deepEqual(calls, [12]); +}); + +test("cached issue comments lookup does not cache malformed fetch results", async () => { + const cache = new Map(); + let syncCalls = 0; + const syncLookup = createCachedIssueCommentsLookup(() => { + syncCalls += 1; + return "bad" as never; + }, cache); + + assert.deepEqual(syncLookup(12), []); + assert.deepEqual(syncLookup(12), []); + assert.equal(syncCalls, 2); + + let asyncCalls = 0; + const asyncLookup = createCachedIssueCommentsLookupAsync(async () => { + asyncCalls += 1; + return "bad" as never; + }, cache); + + assert.deepEqual(await asyncLookup(12), []); + assert.deepEqual(await asyncLookup(12), []); + assert.equal(asyncCalls, 2); +}); + test("autoclose reason parser preserves maintainer wording", () => { assert.equal( autocloseReasonFromCommand("autoclose We don't want this feature"),