From 47a45e54c1abc7aa20a5753eaa734947981393d3 Mon Sep 17 00:00:00 2001 From: damengrandom Date: Sat, 1 Aug 2026 19:36:11 +1000 Subject: [PATCH 1/2] fix(T-33): alert when the curator matches none of the repos it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty curation was logged as "⏭️ No repos curated — skipping send and save." and treated as a quiet day. It cannot be one: collectTopRepos returns null when nothing is trending and never an empty array, so curateOrNotify always receives at least one repo. An empty result can only mean the curator matched none of them. That happens in mergeSummaries, which drops any repo the model did not echo back by exact name. Only prose in the prompt asks for exact names, and the response stays schema-valid either way — so the retry loop never saw it. curateNode retries on a parse error or a schema mismatch, and this is neither. An empty curation is now a retryable error carrying the exact name the model failed to match, so the existing retry and alert machinery applies. Verified against three ordinary reformattings: owner dropped attempts=2 curated=null → notifyError fires title-cased attempts=2 curated=null → notifyError fires whitespace padded attempts=2 curated=null → notifyError fires exact (control) attempts=1 curated 2 Previously every one of those was attempts=1 with no digest and no alert. A model that corrects its names on the retry now recovers and the digest goes out. The dead `!curated.length` branch in curateOrNotify is removed — runCuratorGraph now returns null or a non-empty array. Closes #27 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u --- src/agent/curator-graph.test.ts | 30 ++++++++++++++++++++++++++++++ src/agent/curator.graph.ts | 16 ++++++++++++++-- src/agent/index.ts | 8 ++------ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index 2925862..9bd5ee8 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -91,6 +91,36 @@ test('well-formed JSON that fails the schema produces a distinguishably differen assert.doesNotMatch(result.error ?? '', /not valid JSON/) }) +// mergeSummaries drops any repo the model did not echo back by exact name, so a +// schema-valid response with reformatted names curates nothing at all. The input +// is never empty, so this is total failure, not a quiet day. +const emptyCuratorOutput = JSON.stringify({ repos: [] }) + +test('treats an empty curation as a failure, not a quiet day', async () => { + const fakeCurate = async () => emptyCuratorOutput + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated, null, 'an empty result must not be reported as success') + assert.match(result.error ?? '', /none of the 1 repos/) +}) + +test('retries an empty curation and tells the model which name to echo', async () => { + let calls = 0 + const feedback: (string | undefined)[] = [] + const fakeCurate = async (_repos: TrendingRepo[], fb?: string) => { + calls++ + feedback.push(fb) + return calls === 1 ? emptyCuratorOutput : validCuratorOutput + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(calls, 2, 'an empty curation should be retried') + assert.equal(result.curated?.[0].repo_name, 'foo/bar') + assert.match(feedback[1] ?? '', /foo\/bar/, 'the retry should name the repo the model failed to match') +}) + test('an exception thrown by curate() is caught and recorded instead of crashing the graph', async () => { const fakeCurate = async () => { throw new Error('rate limited') diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index eb164f1..62648b9 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -43,9 +43,21 @@ async function curateNode(state: { repos: TrendingRepo[]; error: string | null; } const parsed = CuratedRepoOutputSchema.safeParse(json) - if (parsed.success) return { curated: parsed.data.repos, error: null } + if (!parsed.success) { + return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } + } + + // The caller only ever passes a non-empty list, so nothing to curate means the + // curator matched none of the repos it was given — a failure, and a retryable + // one, since the feedback tells the model to echo repo_name exactly. + if (!parsed.data.repos.length) { + return { + error: `curator returned summaries for none of the ${state.repos.length} repos it was given — repo_name must match exactly, e.g. "${state.repos[0]?.name}"`, + attempts: state.attempts + 1, + } + } - return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } + return { curated: parsed.data.repos, error: null } } export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { diff --git a/src/agent/index.ts b/src/agent/index.ts index 53d8a4d..ea5f844 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -329,7 +329,8 @@ export class WorkCoordinator { } // Step 3 with its failure handling: null means stop, and the alert has already - // been sent. An empty result is a skip, not a failure — nothing to alert on. + // been sent. collectTopRepos only returns a non-empty list, so there is no + // "nothing to curate" case here — an empty curation is a failure. private static async curateOrNotify(topRepos: TrendingRepo[]): Promise { const { curated, error } = await runCuratorGraph(topRepos, WorkCoordinator.curateRepos) @@ -341,11 +342,6 @@ export class WorkCoordinator { return null } - if (!curated.length) { - logger.info('⏭️ No repos curated — skipping send and save.') - return null - } - return curated } From a41f18d30748948c295f76549674d369bd54b2be Mon Sep 17 00:00:00 2001 From: damengrandom Date: Sat, 1 Aug 2026 19:53:04 +1000 Subject: [PATCH 2/2] refactor(T-33): drop the explanatory comments Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F5CmYPdQcMeLzzJc3iDQ2u --- src/agent/curator-graph.test.ts | 3 --- src/agent/curator.graph.ts | 3 --- src/agent/index.ts | 4 +--- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts index 9bd5ee8..3cadedd 100644 --- a/src/agent/curator-graph.test.ts +++ b/src/agent/curator-graph.test.ts @@ -91,9 +91,6 @@ test('well-formed JSON that fails the schema produces a distinguishably differen assert.doesNotMatch(result.error ?? '', /not valid JSON/) }) -// mergeSummaries drops any repo the model did not echo back by exact name, so a -// schema-valid response with reformatted names curates nothing at all. The input -// is never empty, so this is total failure, not a quiet day. const emptyCuratorOutput = JSON.stringify({ repos: [] }) test('treats an empty curation as a failure, not a quiet day', async () => { diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts index 62648b9..74a285b 100644 --- a/src/agent/curator.graph.ts +++ b/src/agent/curator.graph.ts @@ -47,9 +47,6 @@ async function curateNode(state: { repos: TrendingRepo[]; error: string | null; return { error: `curator output did not match the expected schema: ${parsed.error.message}`, attempts: state.attempts + 1 } } - // The caller only ever passes a non-empty list, so nothing to curate means the - // curator matched none of the repos it was given — a failure, and a retryable - // one, since the feedback tells the model to echo repo_name exactly. if (!parsed.data.repos.length) { return { error: `curator returned summaries for none of the ${state.repos.length} repos it was given — repo_name must match exactly, e.g. "${state.repos[0]?.name}"`, diff --git a/src/agent/index.ts b/src/agent/index.ts index ea5f844..b7f06fa 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -328,9 +328,7 @@ export class WorkCoordinator { return WorkCoordinator.rankByGrowth(allRepos) } - // Step 3 with its failure handling: null means stop, and the alert has already - // been sent. collectTopRepos only returns a non-empty list, so there is no - // "nothing to curate" case here — an empty curation is a failure. + // Step 3 with its failure handling: null means stop, and the alert has already been sent. private static async curateOrNotify(topRepos: TrendingRepo[]): Promise { const { curated, error } = await runCuratorGraph(topRepos, WorkCoordinator.curateRepos)