@@ -18,12 +18,13 @@ import {
1818 getFreshOfficialMinerDetection ,
1919 mostRecentAuditEventForOtherTarget ,
2020 listOpenIssues ,
21+ recordAiUsageEvent ,
2122 recordAuditEvent ,
2223 sumAiEstimatedNeuronsSince ,
2324 upsertOfficialMinerDetection ,
2425} from "../db/repositories" ;
2526import { fetchOfficialGittensorMiner } from "../gittensor/api" ;
26- import { clampNumber , estimateNeurons , utcDayStartIso } from "../services/ai-review" ;
27+ import { BEST_REVIEW_MODELS , RELIABLE_FALLBACK_MODELS , clampNumber , estimateNeurons , utcDayStartIso } from "../services/ai-review" ;
2728import { findUnlinkedIssueCandidates , MAX_CANDIDATES , type CandidateOpenIssue } from "../signals/unlinked-issue-candidates" ;
2829import type { UnlinkedIssueGuardrailConfig } from "../types" ;
2930import { DIFF_CHAR_BUDGET , MAX_TOKENS , verifyUnlinkedIssueMatch } from "./unlinked-issue-match" ;
@@ -62,6 +63,12 @@ const VERIFY_PROMPT_OVERHEAD_CHAR_ESTIMATE = 2_000;
6263// verifyUnlinkedIssueMatch tries a primary model and, ONLY on a thrown error, a fallback -- two calls is the
6364// real worst case per candidate, not the common case, but this budget check must size for the worst case.
6465const VERIFY_MAX_MODEL_ATTEMPTS_PER_CANDIDATE = 2 ;
66+ // Review feedback on #4551: the budget check above reads sumAiEstimatedNeuronsSince, but without a writer
67+ // this feature's own real AI spend never contributed to that counter -- a free rider that respects every
68+ // OTHER feature's usage but never counts its own, so the true aggregate spend could silently exceed
69+ // AI_DAILY_NEURON_BUDGET by however much this feature actually used. recordUnlinkedIssueVerifyUsage (below)
70+ // closes that gap by recording into the SAME shared ai_usage_events table this check reads from.
71+ const UNLINKED_ISSUE_VERIFY_USAGE_FEATURE = "unlinked_issue_verify" ;
6572
6673/** Has this actor already run the AI verifier at or beyond the rate ceiling in the last window, across every
6774 * repo/PR? Fail-safe: a read error resolves to "not rate-limited," so the pre-#4515 unconditional-
@@ -94,6 +101,25 @@ async function isUnlinkedIssueVerifyBudgetExceeded(env: Env, candidateCount: num
94101 return estimatedNeurons > remainingBudget ;
95102}
96103
104+ /** Record ONE candidate's actual AI spend into the shared `ai_usage_events` ledger -- the SAME table
105+ * {@link isUnlinkedIssueVerifyBudgetExceeded} sums from, so this feature's own usage counts against the
106+ * budget it itself enforces on others (see the module-level comment on {@link UNLINKED_ISSUE_VERIFY_USAGE_FEATURE}).
107+ * Records the same worst-case per-candidate estimate the budget check itself uses -- a deliberate over-count
108+ * (verifyUnlinkedIssueMatch's common case is ONE model call, not the two this sizes for), not a precise
109+ * post-hoc token read, mirroring ai-slop.ts's own pre-computed-estimate recording convention. Best-effort: a
110+ * write failure is swallowed (telemetry must never block the gate). */
111+ async function recordUnlinkedIssueVerifyUsage ( env : Env , repoFullName : string , pullNumber : number ) : Promise < void > {
112+ const estimatedNeurons = estimateNeurons ( DIFF_CHAR_BUDGET + VERIFY_PROMPT_OVERHEAD_CHAR_ESTIMATE , MAX_TOKENS , VERIFY_MAX_MODEL_ATTEMPTS_PER_CANDIDATE ) ;
113+ await recordAiUsageEvent ( env , {
114+ feature : UNLINKED_ISSUE_VERIFY_USAGE_FEATURE ,
115+ route : "github_app.unlinked_issue_verify" ,
116+ model : [ BEST_REVIEW_MODELS [ 0 ] , RELIABLE_FALLBACK_MODELS [ 0 ] ] . join ( "+" ) ,
117+ status : "ok" ,
118+ estimatedNeurons,
119+ detail : `unlinked-issue-match verification for ${ repoFullName } #${ pullNumber } ` ,
120+ } ) . catch ( ( ) => undefined ) ;
121+ }
122+
97123/** Minimal cached miner-identity check, deliberately independent of processors.ts's getCachedOfficialMinerDetection
98124 * (same cache table and TTLs, no audit-log side effect -- this call site doesn't need one). Fail-safe: any
99125 * lookup failure resolves to "not a confirmed miner," never the reverse. */
@@ -202,6 +228,9 @@ export async function resolveUnlinkedIssueMatchDisposition(env: Env, input: Reso
202228 if ( await isUnlinkedIssueVerifyBudgetExceeded ( env , candidates . length ) ) return undefined ;
203229 for ( const candidate of candidates ) {
204230 if ( authorLogin ) await recordUnlinkedIssueVerifyAttempt ( env , input . repoFullName , input . pullNumber , authorLogin ) ;
231+ // Record spend regardless of authorLogin -- an AI call happens either way; only the PER-ACTOR rate
232+ // ceiling above needs a known actor, this shared-budget accounting does not.
233+ await recordUnlinkedIssueVerifyUsage ( env , input . repoFullName , input . pullNumber ) ;
205234 const verdict = await verifyUnlinkedIssueMatch ( env , {
206235 prTitle : input . prTitle ,
207236 prBody : input . prBody ,
0 commit comments