|
| 1 | +// The Governor chokepoint (#2340): the single fail-closed decision point every miner write action MUST pass |
| 2 | +// through before executing a `LocalWriteActionSpec` (`src/mcp/local-write-tools.ts`: open_pr, file_issue, |
| 3 | +// apply_labels, post_eligibility_comment, create_branch, delete_branch, generate_tests). This composes the |
| 4 | +// previously-built pure calculators into one verdict -- it is the reason Phase 5 exists. |
| 5 | +// |
| 6 | +// PRECEDENCE ("safest wins", mirroring `resolveAgentActionMode` in `src/settings/agent-execution.ts`): |
| 7 | +// global kill-switch > per-repo pause > dry-run > rate-limit > budget/turn/termination cap > non-convergence |
| 8 | +// > self-reputation throttle > self-plagiarism > allow. |
| 9 | +// The issue's own deliverable names rate-limit, budget caps, and non-convergence explicitly. This module also |
| 10 | +// composes self-reputation-throttle and self-plagiarism, per those two calculators' OWN doc comments |
| 11 | +// (`reputation-throttle.ts`: "the chokepoint can record WHY a submission cadence was scaled"; `self-plagiarism.ts`: |
| 12 | +// "the Governor open_pr chokepoint (#2340) composes this verdict with rate-limit, budget caps, and |
| 13 | +// non-convergence") -- both already ship a `*LedgerEvent` builder keyed on their own boolean |
| 14 | +// throttled/allowed field, so composing them here reuses an existing, already-reviewed gate semantic rather |
| 15 | +// than inventing a new one. Both are evaluated only for `actionClass === "open_pr"` (their own ledger builders |
| 16 | +// hardcode/scope to PR submissions; a label-apply or branch-delete has no diff fingerprint or "submission |
| 17 | +// cadence" to throttle). |
| 18 | +// |
| 19 | +// FAIL CLOSED: any stage that throws (malformed caller input escaping this module's typed boundary) denies |
| 20 | +// immediately with `stage: "internal_error"`, never falls through to `allow`. |
| 21 | +// |
| 22 | +// PURE: no IO, no bucket/ledger persistence. This returns a verdict only; the miner-lib wrapper |
| 23 | +// (`packages/gittensory-miner/lib/governor-chokepoint.js`) owns mutating rate-limit buckets and appending the |
| 24 | +// returned ledger event, mirroring the existing engine-pure/miner-lib-stateful split every sibling module uses. |
| 25 | + |
| 26 | +import type { GovernorLedgerEvent, GovernorLedgerEventType } from "../governor-ledger.js"; |
| 27 | +import type { PortfolioConvergenceInput, PortfolioConvergenceThresholds, PortfolioConvergenceVerdict } from "../portfolio/non-convergence.js"; |
| 28 | +import { classifyPortfolioConvergence, DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS } from "../portfolio/non-convergence.js"; |
| 29 | +import { minerActionModeExecutes, resolveMinerActionMode, type MinerActionMode } from "./action-mode.js"; |
| 30 | +import type { GovernorCapLimits, GovernorCapReport, GovernorCapUsage } from "./budget-cap.js"; |
| 31 | +import { evaluateGovernorCaps } from "./budget-cap.js"; |
| 32 | +import { isMinerKillSwitchActive, resolveMinerKillSwitch, type MinerKillSwitchScope } from "./kill-switch.js"; |
| 33 | +import type { RepoOutcomeHistory, SelfReputationThresholds, SelfReputationThrottleDecision } from "./reputation-throttle.js"; |
| 34 | +import { DEFAULT_SELF_REPUTATION_THRESHOLDS, selfReputationThrottle } from "./reputation-throttle.js"; |
| 35 | +import type { OwnSubmissionRecord, SelfPlagiarismCandidate, SelfPlagiarismConfig, SelfPlagiarismVerdict } from "./self-plagiarism.js"; |
| 36 | +import { DEFAULT_SELF_PLAGIARISM_CONFIG, selfPlagiarismCheck } from "./self-plagiarism.js"; |
| 37 | +import type { WriteRateLimitBackoffStore, WriteRateLimitBucketStore, WriteRateLimitPolicies, WriteRateLimitVerdict } from "./write-rate-limit.js"; |
| 38 | +import { evaluateWriteRateLimit } from "./write-rate-limit.js"; |
| 39 | + |
| 40 | +/** Which stage of the precedence ladder produced the final verdict. */ |
| 41 | +export type GovernorDecisionStage = |
| 42 | + | "kill_switch" |
| 43 | + | "dry_run" |
| 44 | + | "rate_limit" |
| 45 | + | "budget_cap" |
| 46 | + | "non_convergence" |
| 47 | + | "reputation_throttle" |
| 48 | + | "self_plagiarism" |
| 49 | + | "allow" |
| 50 | + | "internal_error"; |
| 51 | + |
| 52 | +/** Action classes that carry a per-submission diff fingerprint / outcome-cadence concept. Reputation-throttle |
| 53 | + * and self-plagiarism are evaluated only for these -- a label-apply or branch-delete has neither. */ |
| 54 | +const SELF_SUBMISSION_ACTION_CLASSES: ReadonlySet<string> = new Set(["open_pr"]); |
| 55 | + |
| 56 | +export type GovernorChokepointInput = { |
| 57 | + actionClass: string; |
| 58 | + repoFullName: string; |
| 59 | + nowMs: number; |
| 60 | + /** Full would-be action spec, logged verbatim on a dry-run shadow (#2342) or a final denial's audit payload. */ |
| 61 | + wouldBeAction: Record<string, unknown>; |
| 62 | + |
| 63 | + // Kill-switch (#2341) + action-mode (#2342). |
| 64 | + killSwitchGlobal: boolean; |
| 65 | + killSwitchRepoPaused?: boolean | null | undefined; |
| 66 | + liveModeGlobalOptIn: boolean; |
| 67 | + liveModeRepoOptIn?: unknown; |
| 68 | + |
| 69 | + // Rate limit (#2344). |
| 70 | + rateLimitBuckets: WriteRateLimitBucketStore; |
| 71 | + rateLimitBackoffAttempts: WriteRateLimitBackoffStore; |
| 72 | + rateLimitPolicies?: WriteRateLimitPolicies | undefined; |
| 73 | + rateLimitRandomFn?: (() => number) | undefined; |
| 74 | + |
| 75 | + // Budget/turn/termination caps. |
| 76 | + capUsage: GovernorCapUsage; |
| 77 | + capLimits: GovernorCapLimits; |
| 78 | + |
| 79 | + // Non-convergence. |
| 80 | + convergenceInput: PortfolioConvergenceInput; |
| 81 | + convergenceThresholds?: PortfolioConvergenceThresholds | undefined; |
| 82 | + |
| 83 | + // Self-reputation throttle + self-plagiarism -- both OPTIONAL: omitted (or actionClass !== "open_pr") skips |
| 84 | + // the stage entirely rather than fabricating a verdict. |
| 85 | + reputationHistory?: RepoOutcomeHistory | undefined; |
| 86 | + reputationThresholds?: SelfReputationThresholds | undefined; |
| 87 | + selfPlagiarismCandidate?: SelfPlagiarismCandidate | undefined; |
| 88 | + selfPlagiarismRecentSubmissions?: readonly OwnSubmissionRecord[] | undefined; |
| 89 | + selfPlagiarismConfig?: SelfPlagiarismConfig | undefined; |
| 90 | +}; |
| 91 | + |
| 92 | +export type GovernorDecisionDetail = { |
| 93 | + killSwitchScope: MinerKillSwitchScope; |
| 94 | + mode: MinerActionMode; |
| 95 | + rateLimit?: WriteRateLimitVerdict; |
| 96 | + budgetCap?: GovernorCapReport; |
| 97 | + convergence?: PortfolioConvergenceVerdict; |
| 98 | + reputation?: SelfReputationThrottleDecision; |
| 99 | + selfPlagiarism?: SelfPlagiarismVerdict; |
| 100 | +}; |
| 101 | + |
| 102 | +export type GovernorDecision = { |
| 103 | + /** True only when every consulted stage allowed AND the resolved mode is `"live"`. */ |
| 104 | + allowed: boolean; |
| 105 | + mode: MinerActionMode; |
| 106 | + stage: GovernorDecisionStage; |
| 107 | + reason: string; |
| 108 | + detail: GovernorDecisionDetail; |
| 109 | + /** The single row to append to the governor ledger for this chokepoint invocation. */ |
| 110 | + ledgerEvent: GovernorLedgerEvent; |
| 111 | +}; |
| 112 | + |
| 113 | +function denyResult(input: { |
| 114 | + stage: GovernorDecisionStage; |
| 115 | + reason: string; |
| 116 | + mode: MinerActionMode; |
| 117 | + detail: GovernorDecisionDetail; |
| 118 | + eventType: GovernorLedgerEventType; |
| 119 | + actionClass: string; |
| 120 | + repoFullName: string; |
| 121 | + extraPayload?: Record<string, unknown>; |
| 122 | +}): GovernorDecision { |
| 123 | + return { |
| 124 | + allowed: false, |
| 125 | + mode: input.mode, |
| 126 | + stage: input.stage, |
| 127 | + reason: input.reason, |
| 128 | + detail: input.detail, |
| 129 | + ledgerEvent: { |
| 130 | + eventType: input.eventType, |
| 131 | + repoFullName: input.repoFullName, |
| 132 | + actionClass: input.actionClass, |
| 133 | + decision: input.stage === "kill_switch" ? "paused" : input.eventType === "throttled" ? "throttle" : "deny", |
| 134 | + reason: input.reason, |
| 135 | + payload: { stage: input.stage, ...input.extraPayload }, |
| 136 | + }, |
| 137 | + }; |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Evaluate every write action against the full precedence ladder and return one fail-closed verdict. See the |
| 142 | + * module doc comment for the exact stage order and which stages are conditional on `actionClass`. |
| 143 | + */ |
| 144 | +export function evaluateGovernorChokepoint(input: GovernorChokepointInput): GovernorDecision { |
| 145 | + const killSwitchScope = resolveMinerKillSwitch({ global: input.killSwitchGlobal, repoPaused: input.killSwitchRepoPaused }); |
| 146 | + const mode = resolveMinerActionMode({ |
| 147 | + killSwitchScope, |
| 148 | + repoLiveModeOptIn: input.liveModeRepoOptIn, |
| 149 | + globalLiveModeOptIn: input.liveModeGlobalOptIn, |
| 150 | + }); |
| 151 | + const baseDetail: GovernorDecisionDetail = { killSwitchScope, mode }; |
| 152 | + |
| 153 | + if (isMinerKillSwitchActive(killSwitchScope)) { |
| 154 | + return denyResult({ |
| 155 | + stage: "kill_switch", |
| 156 | + reason: `${killSwitchScope}_kill_switch_active`, |
| 157 | + mode, |
| 158 | + detail: baseDetail, |
| 159 | + eventType: "kill_switch", |
| 160 | + actionClass: input.actionClass, |
| 161 | + repoFullName: input.repoFullName, |
| 162 | + }); |
| 163 | + } |
| 164 | + |
| 165 | + if (!minerActionModeExecutes(mode)) { |
| 166 | + // dry_run: shadow-log the would-be action without evaluating (or executing) anything further. The other |
| 167 | + // stages are intentionally NOT consulted here -- the ladder's own documented order places dry-run before |
| 168 | + // rate-limit, and a caller wanting a full "what-would-the-full-verdict-be" preview can call this function |
| 169 | + // again with a synthetic live opt-in in a non-production dry-run harness. |
| 170 | + return { |
| 171 | + allowed: false, |
| 172 | + mode, |
| 173 | + stage: "dry_run", |
| 174 | + reason: "dry_run_mode_active", |
| 175 | + detail: baseDetail, |
| 176 | + ledgerEvent: { |
| 177 | + eventType: "allowed", |
| 178 | + repoFullName: input.repoFullName, |
| 179 | + actionClass: input.actionClass, |
| 180 | + decision: "dry_run", |
| 181 | + reason: "dry_run_mode_active", |
| 182 | + payload: { wouldBeAction: input.wouldBeAction }, |
| 183 | + }, |
| 184 | + }; |
| 185 | + } |
| 186 | + |
| 187 | + let rateLimit: WriteRateLimitVerdict; |
| 188 | + try { |
| 189 | + rateLimit = evaluateWriteRateLimit({ |
| 190 | + actionClass: input.actionClass, |
| 191 | + repoFullName: input.repoFullName, |
| 192 | + buckets: input.rateLimitBuckets, |
| 193 | + backoffAttempts: input.rateLimitBackoffAttempts, |
| 194 | + nowMs: input.nowMs, |
| 195 | + ...(input.rateLimitPolicies ? { policies: input.rateLimitPolicies } : {}), |
| 196 | + ...(input.rateLimitRandomFn ? { randomFn: input.rateLimitRandomFn } : {}), |
| 197 | + }); |
| 198 | + } catch (error) { |
| 199 | + return denyResult({ |
| 200 | + stage: "internal_error", |
| 201 | + reason: `rate_limit_calculator_error: ${error instanceof Error ? error.message : String(error)}`, |
| 202 | + mode, |
| 203 | + detail: baseDetail, |
| 204 | + eventType: "denied", |
| 205 | + actionClass: input.actionClass, |
| 206 | + repoFullName: input.repoFullName, |
| 207 | + }); |
| 208 | + } |
| 209 | + const detailWithRateLimit: GovernorDecisionDetail = { ...baseDetail, rateLimit }; |
| 210 | + if (!rateLimit.allowed) { |
| 211 | + return denyResult({ |
| 212 | + stage: "rate_limit", |
| 213 | + reason: rateLimit.reason, |
| 214 | + mode, |
| 215 | + detail: detailWithRateLimit, |
| 216 | + eventType: "throttled", |
| 217 | + actionClass: input.actionClass, |
| 218 | + repoFullName: input.repoFullName, |
| 219 | + extraPayload: { retryAfterMs: rateLimit.retryAfterMs, blockedBy: rateLimit.blockedBy }, |
| 220 | + }); |
| 221 | + } |
| 222 | + |
| 223 | + let budgetCap: GovernorCapReport; |
| 224 | + try { |
| 225 | + budgetCap = evaluateGovernorCaps(input.capUsage, input.capLimits); |
| 226 | + } catch (error) { |
| 227 | + return denyResult({ |
| 228 | + stage: "internal_error", |
| 229 | + reason: `budget_cap_calculator_error: ${error instanceof Error ? error.message : String(error)}`, |
| 230 | + mode, |
| 231 | + detail: detailWithRateLimit, |
| 232 | + eventType: "denied", |
| 233 | + actionClass: input.actionClass, |
| 234 | + repoFullName: input.repoFullName, |
| 235 | + }); |
| 236 | + } |
| 237 | + const detailWithBudget: GovernorDecisionDetail = { ...detailWithRateLimit, budgetCap }; |
| 238 | + if (budgetCap.verdict !== "allowed") { |
| 239 | + return denyResult({ |
| 240 | + stage: "budget_cap", |
| 241 | + reason: `budget_cap_${budgetCap.verdict}`, |
| 242 | + mode, |
| 243 | + detail: detailWithBudget, |
| 244 | + eventType: budgetCap.verdict, |
| 245 | + actionClass: input.actionClass, |
| 246 | + repoFullName: input.repoFullName, |
| 247 | + extraPayload: { budget: budgetCap.budget, turns: budgetCap.turns, termination: budgetCap.termination }, |
| 248 | + }); |
| 249 | + } |
| 250 | + |
| 251 | + let convergence: PortfolioConvergenceVerdict; |
| 252 | + try { |
| 253 | + convergence = classifyPortfolioConvergence(input.convergenceInput, input.convergenceThresholds ?? DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS); |
| 254 | + } catch (error) { |
| 255 | + return denyResult({ |
| 256 | + stage: "internal_error", |
| 257 | + reason: `non_convergence_calculator_error: ${error instanceof Error ? error.message : String(error)}`, |
| 258 | + mode, |
| 259 | + detail: detailWithBudget, |
| 260 | + eventType: "denied", |
| 261 | + actionClass: input.actionClass, |
| 262 | + repoFullName: input.repoFullName, |
| 263 | + }); |
| 264 | + } |
| 265 | + const detailWithConvergence: GovernorDecisionDetail = { ...detailWithBudget, convergence }; |
| 266 | + if (convergence.status === "non_convergent") { |
| 267 | + return denyResult({ |
| 268 | + stage: "non_convergence", |
| 269 | + reason: convergence.reasons.join(" "), |
| 270 | + mode, |
| 271 | + detail: detailWithConvergence, |
| 272 | + eventType: "denied", |
| 273 | + actionClass: input.actionClass, |
| 274 | + repoFullName: input.repoFullName, |
| 275 | + }); |
| 276 | + } |
| 277 | + |
| 278 | + const isSelfSubmissionAction = SELF_SUBMISSION_ACTION_CLASSES.has(input.actionClass); |
| 279 | + |
| 280 | + 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 | + } |
| 299 | + detailWithReputation = { ...detailWithConvergence, reputation }; |
| 300 | + if (reputation.throttled) { |
| 301 | + return denyResult({ |
| 302 | + stage: "reputation_throttle", |
| 303 | + reason: reputation.reason, |
| 304 | + mode, |
| 305 | + detail: detailWithReputation, |
| 306 | + eventType: "throttled", |
| 307 | + actionClass: input.actionClass, |
| 308 | + repoFullName: input.repoFullName, |
| 309 | + extraPayload: { cadenceFactor: reputation.cadenceFactor, unfavorableRatio: reputation.unfavorableRatio }, |
| 310 | + }); |
| 311 | + } |
| 312 | + } |
| 313 | + |
| 314 | + let finalDetail = detailWithReputation; |
| 315 | + // Same `!== undefined` reasoning as the reputation-throttle stage above. |
| 316 | + if (isSelfSubmissionAction && input.selfPlagiarismCandidate !== undefined) { |
| 317 | + let selfPlagiarism: SelfPlagiarismVerdict; |
| 318 | + try { |
| 319 | + selfPlagiarism = selfPlagiarismCheck( |
| 320 | + input.selfPlagiarismCandidate, |
| 321 | + input.selfPlagiarismRecentSubmissions ?? [], |
| 322 | + input.selfPlagiarismConfig ?? DEFAULT_SELF_PLAGIARISM_CONFIG, |
| 323 | + ); |
| 324 | + } catch (error) { |
| 325 | + return denyResult({ |
| 326 | + stage: "internal_error", |
| 327 | + reason: `self_plagiarism_calculator_error: ${error instanceof Error ? error.message : String(error)}`, |
| 328 | + mode, |
| 329 | + detail: detailWithReputation, |
| 330 | + eventType: "denied", |
| 331 | + actionClass: input.actionClass, |
| 332 | + repoFullName: input.repoFullName, |
| 333 | + }); |
| 334 | + } |
| 335 | + finalDetail = { ...detailWithReputation, selfPlagiarism }; |
| 336 | + if (!selfPlagiarism.allowed) { |
| 337 | + return denyResult({ |
| 338 | + stage: "self_plagiarism", |
| 339 | + reason: selfPlagiarism.reason, |
| 340 | + mode, |
| 341 | + detail: finalDetail, |
| 342 | + eventType: selfPlagiarism.eventType, |
| 343 | + actionClass: input.actionClass, |
| 344 | + repoFullName: input.repoFullName, |
| 345 | + extraPayload: { similarity: selfPlagiarism.similarity ?? null }, |
| 346 | + }); |
| 347 | + } |
| 348 | + } |
| 349 | + |
| 350 | + return { |
| 351 | + allowed: true, |
| 352 | + mode, |
| 353 | + stage: "allow", |
| 354 | + reason: "all_governor_checks_passed", |
| 355 | + detail: finalDetail, |
| 356 | + ledgerEvent: { |
| 357 | + eventType: "allowed", |
| 358 | + repoFullName: input.repoFullName, |
| 359 | + actionClass: input.actionClass, |
| 360 | + decision: "allow", |
| 361 | + reason: "all_governor_checks_passed", |
| 362 | + payload: {}, |
| 363 | + }, |
| 364 | + }; |
| 365 | +} |
0 commit comments