Skip to content

Commit d2c8d27

Browse files
authored
fix(orb): webhook redelivery, reputation cadence, DB retention, and gate severity fidelity (#9237)
* fix(db): index retention columns and add delete paths for four never-pruned caches (#9083) No RETENTION_POLICY table had a leading index on its retention timestamp, so pruneExpiredRecords's ctid-keyed batched delete forced a full sequential scan of the whole table on Postgres per batch -- large tables blew the prune job's 30-minute timeout and retention fell permanently behind (signal_snapshots alone reached 787MB at ~44KB/row). Rewrite the delete to range over a real, indexable primary key ordered by the retention column instead of rowid/ctid, and add the matching leading index for every policy table via migration 0191. Also close the "no delete path at all" gap for four read-side caches (grounding_file_content_cache, ai_review_cache, ai_slop_cache, linked_issue_satisfaction_cache) that only enforced their TTL at the read site, and add retention for three previously-untracked append-only logs (review_audit, decision_records, orb_webhook_events). Deliberately deferred: pull_request_files, check_summaries, gate_outcomes, agent_runs, and advisories are current-state tables (upserted per natural key, read as live state for open PRs/checks) rather than append-only logs, so they are excluded from this pass per RETENTION_POLICY's own documented scope -- pruning them needs a read-pattern review this PR doesn't do. * fix(webhook): stop treating stuck queued/superseded deliveries as permanent duplicates (#9054) The dedup guard in enqueueWebhookByEnv only ever exempted 'error' rows from suppression. A row left at 'queued' (the insert happened but WEBHOOKS.send() was lost) or 'superseded' (overwritten by a later coalesced delivery) could never be redelivered: every GitHub retry and every operator "Redeliver" click carries the same delivery_id + payload hash, hits the guard, and is silently discarded as a no-op duplicate forever -- including check_suite.completed, the maybeReReviewOnCiCompletion auto-merge trigger. getWebhookEvent now also returns receivedAt, and a 'queued'/'superseded' row past a 10-minute staleness window is treated the same as an 'error' row: never suppressed. A migration purges the historical backlog of rows this bug already stuck permanently (dead by definition once a full day old), since replaying them is not useful and the code fix means no future delivery can get stuck this way again. Deferred: an active cron sweep that proactively re-triggers GitHub's own redelivery API for still-stuck rows was not implemented -- webhook_events does not persist the raw payload (by design, to avoid growing the exact blob-retention problem #9083 addresses), and this fix already ensures any future stuck delivery is redeliverable well inside GitHub's redelivery window without needing an active poke. * fix(governor): consume cadenceFactor to scale rate-limit cadence instead of hard-denying (#9062) selfReputationThrottle's own doc comment promises a soft cadence throttle that "a recovering ratio restores it -- never a hard permanent ban", but the chokepoint ignored cadenceFactor entirely and hard-denied on ANY throttled verdict. Since submissions are the only source of new decided outcomes, and governor_reputation_history never decayed, a miner that hit the throttle band could never submit again to dilute its ratio back down -- an absorbing, permanent self-ban. evaluateGovernorChokepoint now consumes a non-floored cadenceFactor by scaling the per-repo write-rate-limit window instead of denying; the hard deny is reserved for the extreme "floored" ratio only. The miner-lib wrapper advances its rate-limit bucket against the SAME scaled policy the decision was evaluated with, so state stays consistent with the verdict it recorded. governor_reputation_history also gains a 14-day half-life decay (applied at both read and increment time), so even a floored ratio ages back below the sample-size floor over calendar time with zero new submissions, delivering the "recovering ratio" contract the module already documented. * fix(review): make finding severity load-bearing in check-run display, tighten confidence default, resolve near-miss code names (#9085) Finding severity was purely decorative: a warning-labeled finding (missing_linked_issue, slop_risk_above_threshold) can be the exact reason the gate one-shot-closes a PR, while a critical-labeled one (ai_consensus_defect, under the advisory-only default aiReviewGateMode) can have no gate effect at all. A contributor reading a plain warning icon on the finding that is about to close their PR was being actively misinformed. formatCheckRunOutput/buildCheckRunAnnotations now accept the real blocker codes from the SAME advisory's GateCheckEvaluation (when the caller has one) and use them to correct the displayed severity: an actual blocker always renders at the most alarming level regardless of its authored severity, and a non-blocking finding is capped at warning even if authored critical, so it never cries wolf louder than a genuine one. The parameter is optional and purely additive -- every existing caller that omits it keeps today's exact rendering. Wired the one caller with the evaluation already in scope (processors.ts's check-run publish). Also: - An absent AdvisoryFinding.confidence degraded to 1.0 (maximum certainty) at three gate-side consumption sites, the same "silence is not certainty" anti-pattern CONFIDENCE_WHEN_UNSTATED already fixed at the model-parsing layer. These sites read confidence off a finding object via a path the parser doesn't cover (a producer that omitted it, or the unvalidated cached-advisory JSON parse), so they now share the same 0.5 fallback instead of a second, wrong hardcoded default. - Renamed the near-miss repo_not_registered -> repo_not_cached (mirrors pr_not_cached/issue_not_cached's naming for the identical "not yet synced" shape) to stop it being confused with repo_unregistered, one character away and with the opposite gate consequence (repo_not_cached holds the gate for a human; repo_unregistered is a non-blocking advisory warning). The engine's gate-advisory.ts twin gets the matching confidence-default and rename fixes to stay in lock-step with the host copy.
1 parent 2206537 commit d2c8d27

26 files changed

Lines changed: 999 additions & 124 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- #9083: no RETENTION_POLICY (src/db/retention.ts) table had an index whose LEADING column is its
2+
-- retention timestamp -- every existing index on these tables either doesn't exist at all
3+
-- (webhook_events, signal_snapshots, score_previews, repo_snapshots) or has the timestamp only in a
4+
-- trailing position (audit_events, ai_usage_events, product_usage_events,
5+
-- github_rate_limit_observations, agent_context_snapshots, notification_deliveries). Paired with the
6+
-- pruneExpiredRecords rewrite (PK-ordered range delete instead of a rowid/ctid semi-join), the inner
7+
-- SELECT in each batched delete is now an index range scan instead of a full table scan.
8+
CREATE INDEX IF NOT EXISTS idx_webhook_events_received_at ON webhook_events(received_at);
9+
CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events(created_at);
10+
CREATE INDEX IF NOT EXISTS idx_ai_usage_events_created_at ON ai_usage_events(created_at);
11+
CREATE INDEX IF NOT EXISTS idx_product_usage_events_occurred_at ON product_usage_events(occurred_at);
12+
CREATE INDEX IF NOT EXISTS idx_github_rate_limit_observations_observed_at ON github_rate_limit_observations(observed_at);
13+
CREATE INDEX IF NOT EXISTS idx_signal_snapshots_generated_at ON signal_snapshots(generated_at);
14+
CREATE INDEX IF NOT EXISTS idx_score_previews_generated_at ON score_previews(generated_at);
15+
CREATE INDEX IF NOT EXISTS idx_repo_snapshots_fetched_at ON repo_snapshots(fetched_at);
16+
CREATE INDEX IF NOT EXISTS idx_agent_context_snapshots_created_at ON agent_context_snapshots(created_at);
17+
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created_at ON notification_deliveries(created_at);
18+
CREATE INDEX IF NOT EXISTS idx_predicted_gate_calls_created_at ON predicted_gate_calls(created_at);
19+
20+
-- #9083: the four never-pruned caches (grounding_file_content_cache, ai_review_cache, ai_slop_cache,
21+
-- linked_issue_satisfaction_cache) newly gained a retention rule in the same change -- index their
22+
-- timestamp column too so the new prune sweep stays cheap as they grow.
23+
CREATE INDEX IF NOT EXISTS idx_grounding_file_content_cache_fetched_at ON grounding_file_content_cache(fetched_at);
24+
CREATE INDEX IF NOT EXISTS idx_ai_review_cache_created_at ON ai_review_cache(created_at);
25+
CREATE INDEX IF NOT EXISTS idx_ai_slop_cache_created_at ON ai_slop_cache(created_at);
26+
CREATE INDEX IF NOT EXISTS idx_linked_issue_satisfaction_cache_created_at ON linked_issue_satisfaction_cache(created_at);
27+
28+
-- #9083: the three newly-retained append-only tables (review_audit, decision_records, orb_webhook_events)
29+
-- each already had an index touching their timestamp column, but only in a trailing/combined position --
30+
-- add the leading single-column index the new PK-ordered delete needs.
31+
CREATE INDEX IF NOT EXISTS idx_review_audit_created_at ON review_audit(created_at);
32+
CREATE INDEX IF NOT EXISTS idx_decision_records_created_at ON decision_records(created_at);
33+
CREATE INDEX IF NOT EXISTS idx_orb_webhook_events_received_at ON orb_webhook_events(received_at);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- #9054: one-off purge of webhook_events rows PERMANENTLY stuck at 'queued'/'superseded' by the dedup-guard
2+
-- bug fixed alongside this migration (src/github/webhook.ts's enqueueWebhookByEnv). Before that fix, a row
3+
-- left at 'queued' (the insert happened but WEBHOOKS.send() was lost, or the enqueued job vanished) or
4+
-- 'superseded' (overwritten by a later coalesced delivery before either was claimed) could NEVER be
5+
-- redelivered: every GitHub retry and every operator "Redeliver" click carries the identical delivery_id and
6+
-- (usually) the identical payload hash, hit the dedup guard, and was silently discarded as a no-op
7+
-- duplicate, forever.
8+
--
9+
-- On the live box this incident left 5,547 such rows (4,900 of them check_suite.completed -- the
10+
-- maybeReReviewOnCiCompletion auto-merge trigger), all from a ~3.5-week cutover-era window that was already
11+
-- quiescent by the time this was diagnosed. Replaying them is not useful: the code-level fix means any
12+
-- FUTURE stuck delivery becomes redeliverable after STALE_QUEUED_WEBHOOK_MS (10 minutes) rather than never,
13+
-- so there is no ongoing gap to backfill, and the PRs behind this historical backlog are long since
14+
-- resolved one way or another. Simply deleting them (rather than replaying) is what the issue itself called
15+
-- for, so the stuck-delivery metric is meaningful again going forward.
16+
--
17+
-- Applies once, wherever/whenever this migration runs: any 'queued'/'superseded' row still unprocessed a
18+
-- full day after receipt is dead by definition (real processing completes in well under a second), so this
19+
-- is safe as a general one-time data-hygiene sweep on any environment, and a no-op on a fresh database with
20+
-- no history to purge.
21+
DELETE FROM webhook_events
22+
WHERE status IN ('queued', 'superseded')
23+
AND processed_at IS NULL
24+
AND received_at < datetime('now', '-1 day');

packages/loopover-engine/src/advisory/gate-advisory.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ function sanitizeForCheckRun(text: string): string {
3939
}
4040

4141
const DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE = 0.93;
42+
// #9085 (host-parity): mirrors CONFIDENCE_WHEN_UNSTATED (src/services/ai-review.ts) -- kept as a local constant
43+
// rather than an import, matching this file's own divergence design (it deliberately never pulls in the host's
44+
// signals/services subsystem). An ABSENT `finding.confidence` must never read as maximum certainty (1.0):
45+
// "silence is not certainty" (#8833).
46+
const CONFIDENCE_WHEN_UNSTATED = 0.5;
4247
/** Exported to mirror the src twin (#8224): loopover's LOOSENABLE_KNOBS registry anchors the slop knob's
4348
* shipped value on this constant (divided by 100 onto the corpus's confidence scale). Value unchanged. */
4449
export const DEFAULT_SLOP_BLOCK_THRESHOLD = 60;
@@ -196,12 +201,15 @@ export function buildPullRequestAdvisory(
196201
const targetKey = pr ? `${repoFullName}#${pr.number}` : `${repoFullName}#unknown`;
197202
const findings: AdvisoryFinding[] = [];
198203
if (!repo) {
204+
// #9085 (host-parity): renamed from `repo_not_registered` -- one character away from, and with the
205+
// OPPOSITE gate consequence of, `repo_unregistered` below (addRepoFindings). See the host copy
206+
// (src/rules/advisory.ts) for the full rename rationale.
199207
findings.push({
200-
code: "repo_not_registered",
208+
code: "repo_not_cached",
201209
severity: "warning",
202-
title: "Repository registration is unknown",
203-
detail: "LoopOver cannot evaluate repo-specific rules until registry data is available.",
204-
action: "Refresh the Gittensor registry snapshot.",
210+
title: "Repository is not yet cached",
211+
detail: "LoopOver has not synced this repository yet, so repo-specific rules cannot be evaluated.",
212+
action: "Wait for the next repo sync, or re-deliver the installation webhook.",
205213
});
206214
} else {
207215
addRepoFindings(repo, findings);
@@ -634,7 +642,8 @@ function isEvaluationBlocker(code: string, policy: GateCheckPolicy): boolean {
634642
// pre_merge_check_unresolved: an enforced path-gated pre-merge check whose changed-file set could not be
635643
// resolved — loopover cannot evaluate it yet, so the gate is NEUTRAL (held) and re-evaluates on the next
636644
// sync, rather than auto-merging past the unverified requirement or hard-closing on a transient miss. (#review-audit)
637-
if (code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved") return true;
645+
// #9085 (host-parity): renamed from `repo_not_registered`.
646+
if (code === "repo_not_cached" || code === "repo_not_seen" || code === "pr_not_cached" || code === "pre_merge_check_unresolved") return true;
638647
// cla_check_unresolved (#2564): the CLA-bot check-run's conclusion could not be resolved. Unlike the codes
639648
// above (which are never mode-gated), evaluateClaCheck runs for BOTH claGateMode "advisory" and "block" (so
640649
// the finding surfaces either way) — only "block" should ever HOLD the gate on an unresolved check-run.
@@ -678,7 +687,7 @@ function isConfiguredGateBlocker(finding: AdvisoryFinding, policy: GateCheckPoli
678687
if (!gatePolicyBlocks(policy.aiReviewGateMode, "advisory")) return false;
679688
if ((policy.aiReviewLowConfidenceDisposition ?? "hold_for_review") === "advisory_only") {
680689
const floor = policy.aiReviewCloseConfidence ?? DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE;
681-
const confidence = finding.confidence ?? 1;
690+
const confidence = finding.confidence ?? CONFIDENCE_WHEN_UNSTATED;
682691
if (confidence < floor) return false;
683692
}
684693
return true;

packages/loopover-engine/src/governor/chokepoint.ts

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { DEFAULT_SELF_REPUTATION_THRESHOLDS, selfReputationThrottle } from "./re
3535
import type { OwnSubmissionRecord, SelfPlagiarismCandidate, SelfPlagiarismConfig, SelfPlagiarismVerdict } from "./self-plagiarism.js";
3636
import { DEFAULT_SELF_PLAGIARISM_CONFIG, selfPlagiarismCheck } from "./self-plagiarism.js";
3737
import type { WriteRateLimitBackoffStore, WriteRateLimitBucketStore, WriteRateLimitPolicies, WriteRateLimitVerdict } from "./write-rate-limit.js";
38-
import { evaluateWriteRateLimit } from "./write-rate-limit.js";
38+
import { DEFAULT_WRITE_RATE_LIMIT_POLICIES, evaluateWriteRateLimit } from "./write-rate-limit.js";
3939

4040
/** Which stage of the precedence ladder produced the final verdict. */
4141
export type GovernorDecisionStage =
@@ -97,6 +97,13 @@ export type GovernorDecisionDetail = {
9797
convergence?: PortfolioConvergenceVerdict;
9898
reputation?: SelfReputationThrottleDecision;
9999
selfPlagiarism?: SelfPlagiarismVerdict;
100+
/** #9062: the rate-limit policies actually used to reach `rateLimit` -- identical to the caller-supplied
101+
* `rateLimitPolicies` (or the engine default) UNLESS a non-floored reputation throttle scaled the
102+
* per-repo window. Present once the rate-limit stage has run (mirrors `rateLimit`'s own presence). The
103+
* stateful miner-lib wrapper (`governor-chokepoint.js`) reads this back to advance the SAME bucket
104+
* arithmetic post-decision that produced this verdict -- reusing the caller's raw, un-scaled policy there
105+
* instead would silently disagree with the decision it is recording state for. */
106+
effectiveRateLimitPolicies?: WriteRateLimitPolicies;
100107
};
101108

102109
export type GovernorDecision = {
@@ -110,6 +117,30 @@ export type GovernorDecision = {
110117
ledgerEvent: GovernorLedgerEvent;
111118
};
112119

120+
/** #9062: stretch the PER-REPO rate-limit window for `actionClass` by `1 / cadenceFactor` so a throttled
121+
* miner's write cadence on THIS repo degrades proportionally instead of being denied outright --
122+
* reputation-throttle.ts's own doc comment promises "a recovering ratio restores it -- never a hard
123+
* permanent ban", so consuming `cadenceFactor` here (rather than discarding it, as before) is what actually
124+
* delivers that contract for the common (non-floored) `"throttled"` case. Only the per-repo scope is
125+
* scaled: `RepoOutcomeHistory` is scoped to ONE repo, so a bad ratio there must not also slow the miner's
126+
* unrelated global write velocity across every other repo it works on. Never called with
127+
* `cadenceFactor >= 1` (the caller only invokes this once it has already checked that). */
128+
function scaleRateLimitPerRepoWindow(policies: WriteRateLimitPolicies, actionClass: string, cadenceFactor: number): WriteRateLimitPolicies {
129+
const perRepoConfig = policies.perRepo[actionClass];
130+
if (!perRepoConfig) return policies;
131+
// Defensive floor: cadenceFactor is documented as "never 0" by resolveSelfReputationThresholds's clamped
132+
// minCadenceFactor, but that field has no enforced lower bound of its own -- guard the division regardless
133+
// of what a caller-supplied threshold override might set it to.
134+
const safeFactor = cadenceFactor > 0 ? cadenceFactor : 0.001;
135+
return {
136+
...policies,
137+
perRepo: {
138+
...policies.perRepo,
139+
[actionClass]: { ...perRepoConfig, windowMs: Math.round(perRepoConfig.windowMs / safeFactor) },
140+
},
141+
};
142+
}
143+
113144
function denyResult(input: {
114145
stage: GovernorDecisionStage;
115146
reason: string;
@@ -184,6 +215,35 @@ export function evaluateGovernorChokepoint(input: GovernorChokepointInput): Gove
184215
};
185216
}
186217

218+
const isSelfSubmissionAction = SELF_SUBMISSION_ACTION_CLASSES.has(input.actionClass);
219+
220+
// #9062: computed here -- BEFORE rate-limit -- rather than after budget/convergence (where its own HARD
221+
// DENY for `reason === "floored"` still stays, preserving the ladder's documented precedence for that
222+
// path) so a non-floored `cadenceFactor` can scale the per-repo rate-limit window below. Discarding
223+
// cadenceFactor and hard-denying on ANY throttled verdict (the pre-#9062 behavior) is exactly what turned a
224+
// recoverable soft throttle into a permanent self-ban: submissions are the miner's only source of new
225+
// decided outcomes, so a miner that can never submit can never dilute a bad ratio back down.
226+
let reputation: SelfReputationThrottleDecision | undefined;
227+
if (isSelfSubmissionAction && input.reputationHistory !== undefined) {
228+
try {
229+
reputation = selfReputationThrottle(input.reputationHistory, input.reputationThresholds ?? DEFAULT_SELF_REPUTATION_THRESHOLDS);
230+
} catch (error) {
231+
return denyResult({
232+
stage: "internal_error",
233+
reason: `reputation_throttle_calculator_error: ${error instanceof Error ? error.message : String(error)}`,
234+
mode,
235+
detail: baseDetail,
236+
eventType: "denied",
237+
actionClass: input.actionClass,
238+
repoFullName: input.repoFullName,
239+
});
240+
}
241+
}
242+
const rateLimitPolicies =
243+
reputation && reputation.cadenceFactor < 1
244+
? scaleRateLimitPerRepoWindow(input.rateLimitPolicies ?? DEFAULT_WRITE_RATE_LIMIT_POLICIES, input.actionClass, reputation.cadenceFactor)
245+
: input.rateLimitPolicies;
246+
187247
let rateLimit: WriteRateLimitVerdict;
188248
try {
189249
rateLimit = evaluateWriteRateLimit({
@@ -192,7 +252,7 @@ export function evaluateGovernorChokepoint(input: GovernorChokepointInput): Gove
192252
buckets: input.rateLimitBuckets,
193253
backoffAttempts: input.rateLimitBackoffAttempts,
194254
nowMs: input.nowMs,
195-
...(input.rateLimitPolicies ? { policies: input.rateLimitPolicies } : {}),
255+
...(rateLimitPolicies ? { policies: rateLimitPolicies } : {}),
196256
...(input.rateLimitRandomFn ? { randomFn: input.rateLimitRandomFn } : {}),
197257
});
198258
} catch (error) {
@@ -206,7 +266,11 @@ export function evaluateGovernorChokepoint(input: GovernorChokepointInput): Gove
206266
repoFullName: input.repoFullName,
207267
});
208268
}
209-
const detailWithRateLimit: GovernorDecisionDetail = { ...baseDetail, rateLimit };
269+
const detailWithRateLimit: GovernorDecisionDetail = {
270+
...baseDetail,
271+
rateLimit,
272+
effectiveRateLimitPolicies: rateLimitPolicies ?? DEFAULT_WRITE_RATE_LIMIT_POLICIES,
273+
};
210274
if (!rateLimit.allowed) {
211275
return denyResult({
212276
stage: "rate_limit",
@@ -216,7 +280,13 @@ export function evaluateGovernorChokepoint(input: GovernorChokepointInput): Gove
216280
eventType: "throttled",
217281
actionClass: input.actionClass,
218282
repoFullName: input.repoFullName,
219-
extraPayload: { retryAfterMs: rateLimit.retryAfterMs, blockedBy: rateLimit.blockedBy },
283+
extraPayload: {
284+
retryAfterMs: rateLimit.retryAfterMs,
285+
blockedBy: rateLimit.blockedBy,
286+
// Surfaced only when the reputation throttle actually degraded this window, so an operator reading
287+
// the ledger can tell "genuinely busy" apart from "a reputation-scaled cadence caught up with it".
288+
...(reputation && reputation.cadenceFactor < 1 ? { reputationCadenceFactor: reputation.cadenceFactor } : {}),
289+
},
220290
});
221291
}
222292

@@ -275,29 +345,17 @@ export function evaluateGovernorChokepoint(input: GovernorChokepointInput): Gove
275345
});
276346
}
277347

278-
const isSelfSubmissionAction = SELF_SUBMISSION_ACTION_CLASSES.has(input.actionClass);
279-
348+
// #9062: `reputation` was already computed above (before rate-limit, so its cadenceFactor could scale that
349+
// stage's per-repo window). Its own hard-deny stays at its documented ladder position -- AFTER budget/
350+
// convergence -- but is now reserved for `reason === "floored"` only: the extreme, near-certain-abuse case.
351+
// A plain `"throttled"` verdict already had its cadence effect folded into the rate-limit evaluation above,
352+
// so it no longer denies here at all -- it either already got caught by the tightened rate limit, or it
353+
// didn't need to be, and either way the miner keeps submitting (at a degraded cadence) instead of being
354+
// structurally unable to ever generate the new clean outcomes that would dilute its ratio back down.
280355
let detailWithReputation = detailWithConvergence;
281-
// `!== undefined` (not a truthy check): an omitted key means "skip this stage"; any OTHER value the caller
282-
// supplied -- including a bad `null` from a malformed upstream source -- must reach the calculator and, if it
283-
// cannot handle it, fail closed via the catch below, never silently skip.
284-
if (isSelfSubmissionAction && input.reputationHistory !== undefined) {
285-
let reputation: SelfReputationThrottleDecision;
286-
try {
287-
reputation = selfReputationThrottle(input.reputationHistory, input.reputationThresholds ?? DEFAULT_SELF_REPUTATION_THRESHOLDS);
288-
} catch (error) {
289-
return denyResult({
290-
stage: "internal_error",
291-
reason: `reputation_throttle_calculator_error: ${error instanceof Error ? error.message : String(error)}`,
292-
mode,
293-
detail: detailWithConvergence,
294-
eventType: "denied",
295-
actionClass: input.actionClass,
296-
repoFullName: input.repoFullName,
297-
});
298-
}
356+
if (reputation) {
299357
detailWithReputation = { ...detailWithConvergence, reputation };
300-
if (reputation.throttled) {
358+
if (reputation.reason === "floored") {
301359
return denyResult({
302360
stage: "reputation_throttle",
303361
reason: reputation.reason,

0 commit comments

Comments
 (0)