diff --git a/packages/gittensory-engine/src/governor/chokepoint.ts b/packages/gittensory-engine/src/governor/chokepoint.ts new file mode 100644 index 0000000000..3fe3304ed1 --- /dev/null +++ b/packages/gittensory-engine/src/governor/chokepoint.ts @@ -0,0 +1,365 @@ +// The Governor chokepoint (#2340): the single fail-closed decision point every miner write action MUST pass +// through before executing a `LocalWriteActionSpec` (`src/mcp/local-write-tools.ts`: open_pr, file_issue, +// apply_labels, post_eligibility_comment, create_branch, delete_branch, generate_tests). This composes the +// previously-built pure calculators into one verdict -- it is the reason Phase 5 exists. +// +// PRECEDENCE ("safest wins", mirroring `resolveAgentActionMode` in `src/settings/agent-execution.ts`): +// global kill-switch > per-repo pause > dry-run > rate-limit > budget/turn/termination cap > non-convergence +// > self-reputation throttle > self-plagiarism > allow. +// The issue's own deliverable names rate-limit, budget caps, and non-convergence explicitly. This module also +// composes self-reputation-throttle and self-plagiarism, per those two calculators' OWN doc comments +// (`reputation-throttle.ts`: "the chokepoint can record WHY a submission cadence was scaled"; `self-plagiarism.ts`: +// "the Governor open_pr chokepoint (#2340) composes this verdict with rate-limit, budget caps, and +// non-convergence") -- both already ship a `*LedgerEvent` builder keyed on their own boolean +// throttled/allowed field, so composing them here reuses an existing, already-reviewed gate semantic rather +// than inventing a new one. Both are evaluated only for `actionClass === "open_pr"` (their own ledger builders +// hardcode/scope to PR submissions; a label-apply or branch-delete has no diff fingerprint or "submission +// cadence" to throttle). +// +// FAIL CLOSED: any stage that throws (malformed caller input escaping this module's typed boundary) denies +// immediately with `stage: "internal_error"`, never falls through to `allow`. +// +// PURE: no IO, no bucket/ledger persistence. This returns a verdict only; the miner-lib wrapper +// (`packages/gittensory-miner/lib/governor-chokepoint.js`) owns mutating rate-limit buckets and appending the +// returned ledger event, mirroring the existing engine-pure/miner-lib-stateful split every sibling module uses. + +import type { GovernorLedgerEvent, GovernorLedgerEventType } from "../governor-ledger.js"; +import type { PortfolioConvergenceInput, PortfolioConvergenceThresholds, PortfolioConvergenceVerdict } from "../portfolio/non-convergence.js"; +import { classifyPortfolioConvergence, DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS } from "../portfolio/non-convergence.js"; +import { minerActionModeExecutes, resolveMinerActionMode, type MinerActionMode } from "./action-mode.js"; +import type { GovernorCapLimits, GovernorCapReport, GovernorCapUsage } from "./budget-cap.js"; +import { evaluateGovernorCaps } from "./budget-cap.js"; +import { isMinerKillSwitchActive, resolveMinerKillSwitch, type MinerKillSwitchScope } from "./kill-switch.js"; +import type { RepoOutcomeHistory, SelfReputationThresholds, SelfReputationThrottleDecision } from "./reputation-throttle.js"; +import { DEFAULT_SELF_REPUTATION_THRESHOLDS, selfReputationThrottle } from "./reputation-throttle.js"; +import type { OwnSubmissionRecord, SelfPlagiarismCandidate, SelfPlagiarismConfig, SelfPlagiarismVerdict } from "./self-plagiarism.js"; +import { DEFAULT_SELF_PLAGIARISM_CONFIG, selfPlagiarismCheck } from "./self-plagiarism.js"; +import type { WriteRateLimitBackoffStore, WriteRateLimitBucketStore, WriteRateLimitPolicies, WriteRateLimitVerdict } from "./write-rate-limit.js"; +import { evaluateWriteRateLimit } from "./write-rate-limit.js"; + +/** Which stage of the precedence ladder produced the final verdict. */ +export type GovernorDecisionStage = + | "kill_switch" + | "dry_run" + | "rate_limit" + | "budget_cap" + | "non_convergence" + | "reputation_throttle" + | "self_plagiarism" + | "allow" + | "internal_error"; + +/** Action classes that carry a per-submission diff fingerprint / outcome-cadence concept. Reputation-throttle + * and self-plagiarism are evaluated only for these -- a label-apply or branch-delete has neither. */ +const SELF_SUBMISSION_ACTION_CLASSES: ReadonlySet = new Set(["open_pr"]); + +export type GovernorChokepointInput = { + actionClass: string; + repoFullName: string; + nowMs: number; + /** Full would-be action spec, logged verbatim on a dry-run shadow (#2342) or a final denial's audit payload. */ + wouldBeAction: Record; + + // Kill-switch (#2341) + action-mode (#2342). + killSwitchGlobal: boolean; + killSwitchRepoPaused?: boolean | null | undefined; + liveModeGlobalOptIn: boolean; + liveModeRepoOptIn?: unknown; + + // Rate limit (#2344). + rateLimitBuckets: WriteRateLimitBucketStore; + rateLimitBackoffAttempts: WriteRateLimitBackoffStore; + rateLimitPolicies?: WriteRateLimitPolicies | undefined; + rateLimitRandomFn?: (() => number) | undefined; + + // Budget/turn/termination caps. + capUsage: GovernorCapUsage; + capLimits: GovernorCapLimits; + + // Non-convergence. + convergenceInput: PortfolioConvergenceInput; + convergenceThresholds?: PortfolioConvergenceThresholds | undefined; + + // Self-reputation throttle + self-plagiarism -- both OPTIONAL: omitted (or actionClass !== "open_pr") skips + // the stage entirely rather than fabricating a verdict. + reputationHistory?: RepoOutcomeHistory | undefined; + reputationThresholds?: SelfReputationThresholds | undefined; + selfPlagiarismCandidate?: SelfPlagiarismCandidate | undefined; + selfPlagiarismRecentSubmissions?: readonly OwnSubmissionRecord[] | undefined; + selfPlagiarismConfig?: SelfPlagiarismConfig | undefined; +}; + +export type GovernorDecisionDetail = { + killSwitchScope: MinerKillSwitchScope; + mode: MinerActionMode; + rateLimit?: WriteRateLimitVerdict; + budgetCap?: GovernorCapReport; + convergence?: PortfolioConvergenceVerdict; + reputation?: SelfReputationThrottleDecision; + selfPlagiarism?: SelfPlagiarismVerdict; +}; + +export type GovernorDecision = { + /** True only when every consulted stage allowed AND the resolved mode is `"live"`. */ + allowed: boolean; + mode: MinerActionMode; + stage: GovernorDecisionStage; + reason: string; + detail: GovernorDecisionDetail; + /** The single row to append to the governor ledger for this chokepoint invocation. */ + ledgerEvent: GovernorLedgerEvent; +}; + +function denyResult(input: { + stage: GovernorDecisionStage; + reason: string; + mode: MinerActionMode; + detail: GovernorDecisionDetail; + eventType: GovernorLedgerEventType; + actionClass: string; + repoFullName: string; + extraPayload?: Record; +}): GovernorDecision { + return { + allowed: false, + mode: input.mode, + stage: input.stage, + reason: input.reason, + detail: input.detail, + ledgerEvent: { + eventType: input.eventType, + repoFullName: input.repoFullName, + actionClass: input.actionClass, + decision: input.stage === "kill_switch" ? "paused" : input.eventType === "throttled" ? "throttle" : "deny", + reason: input.reason, + payload: { stage: input.stage, ...input.extraPayload }, + }, + }; +} + +/** + * Evaluate every write action against the full precedence ladder and return one fail-closed verdict. See the + * module doc comment for the exact stage order and which stages are conditional on `actionClass`. + */ +export function evaluateGovernorChokepoint(input: GovernorChokepointInput): GovernorDecision { + const killSwitchScope = resolveMinerKillSwitch({ global: input.killSwitchGlobal, repoPaused: input.killSwitchRepoPaused }); + const mode = resolveMinerActionMode({ + killSwitchScope, + repoLiveModeOptIn: input.liveModeRepoOptIn, + globalLiveModeOptIn: input.liveModeGlobalOptIn, + }); + const baseDetail: GovernorDecisionDetail = { killSwitchScope, mode }; + + if (isMinerKillSwitchActive(killSwitchScope)) { + return denyResult({ + stage: "kill_switch", + reason: `${killSwitchScope}_kill_switch_active`, + mode, + detail: baseDetail, + eventType: "kill_switch", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + + if (!minerActionModeExecutes(mode)) { + // dry_run: shadow-log the would-be action without evaluating (or executing) anything further. The other + // stages are intentionally NOT consulted here -- the ladder's own documented order places dry-run before + // rate-limit, and a caller wanting a full "what-would-the-full-verdict-be" preview can call this function + // again with a synthetic live opt-in in a non-production dry-run harness. + return { + allowed: false, + mode, + stage: "dry_run", + reason: "dry_run_mode_active", + detail: baseDetail, + ledgerEvent: { + eventType: "allowed", + repoFullName: input.repoFullName, + actionClass: input.actionClass, + decision: "dry_run", + reason: "dry_run_mode_active", + payload: { wouldBeAction: input.wouldBeAction }, + }, + }; + } + + let rateLimit: WriteRateLimitVerdict; + try { + rateLimit = evaluateWriteRateLimit({ + actionClass: input.actionClass, + repoFullName: input.repoFullName, + buckets: input.rateLimitBuckets, + backoffAttempts: input.rateLimitBackoffAttempts, + nowMs: input.nowMs, + ...(input.rateLimitPolicies ? { policies: input.rateLimitPolicies } : {}), + ...(input.rateLimitRandomFn ? { randomFn: input.rateLimitRandomFn } : {}), + }); + } catch (error) { + return denyResult({ + stage: "internal_error", + reason: `rate_limit_calculator_error: ${error instanceof Error ? error.message : String(error)}`, + mode, + detail: baseDetail, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + const detailWithRateLimit: GovernorDecisionDetail = { ...baseDetail, rateLimit }; + if (!rateLimit.allowed) { + return denyResult({ + stage: "rate_limit", + reason: rateLimit.reason, + mode, + detail: detailWithRateLimit, + eventType: "throttled", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + extraPayload: { retryAfterMs: rateLimit.retryAfterMs, blockedBy: rateLimit.blockedBy }, + }); + } + + let budgetCap: GovernorCapReport; + try { + budgetCap = evaluateGovernorCaps(input.capUsage, input.capLimits); + } catch (error) { + return denyResult({ + stage: "internal_error", + reason: `budget_cap_calculator_error: ${error instanceof Error ? error.message : String(error)}`, + mode, + detail: detailWithRateLimit, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + const detailWithBudget: GovernorDecisionDetail = { ...detailWithRateLimit, budgetCap }; + if (budgetCap.verdict !== "allowed") { + return denyResult({ + stage: "budget_cap", + reason: `budget_cap_${budgetCap.verdict}`, + mode, + detail: detailWithBudget, + eventType: budgetCap.verdict, + actionClass: input.actionClass, + repoFullName: input.repoFullName, + extraPayload: { budget: budgetCap.budget, turns: budgetCap.turns, termination: budgetCap.termination }, + }); + } + + let convergence: PortfolioConvergenceVerdict; + try { + convergence = classifyPortfolioConvergence(input.convergenceInput, input.convergenceThresholds ?? DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS); + } catch (error) { + return denyResult({ + stage: "internal_error", + reason: `non_convergence_calculator_error: ${error instanceof Error ? error.message : String(error)}`, + mode, + detail: detailWithBudget, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + const detailWithConvergence: GovernorDecisionDetail = { ...detailWithBudget, convergence }; + if (convergence.status === "non_convergent") { + return denyResult({ + stage: "non_convergence", + reason: convergence.reasons.join(" "), + mode, + detail: detailWithConvergence, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + + const isSelfSubmissionAction = SELF_SUBMISSION_ACTION_CLASSES.has(input.actionClass); + + let detailWithReputation = detailWithConvergence; + // `!== undefined` (not a truthy check): an omitted key means "skip this stage"; any OTHER value the caller + // supplied -- including a bad `null` from a malformed upstream source -- must reach the calculator and, if it + // cannot handle it, fail closed via the catch below, never silently skip. + if (isSelfSubmissionAction && input.reputationHistory !== undefined) { + let reputation: SelfReputationThrottleDecision; + try { + reputation = selfReputationThrottle(input.reputationHistory, input.reputationThresholds ?? DEFAULT_SELF_REPUTATION_THRESHOLDS); + } catch (error) { + return denyResult({ + stage: "internal_error", + reason: `reputation_throttle_calculator_error: ${error instanceof Error ? error.message : String(error)}`, + mode, + detail: detailWithConvergence, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + detailWithReputation = { ...detailWithConvergence, reputation }; + if (reputation.throttled) { + return denyResult({ + stage: "reputation_throttle", + reason: reputation.reason, + mode, + detail: detailWithReputation, + eventType: "throttled", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + extraPayload: { cadenceFactor: reputation.cadenceFactor, unfavorableRatio: reputation.unfavorableRatio }, + }); + } + } + + let finalDetail = detailWithReputation; + // Same `!== undefined` reasoning as the reputation-throttle stage above. + if (isSelfSubmissionAction && input.selfPlagiarismCandidate !== undefined) { + let selfPlagiarism: SelfPlagiarismVerdict; + try { + selfPlagiarism = selfPlagiarismCheck( + input.selfPlagiarismCandidate, + input.selfPlagiarismRecentSubmissions ?? [], + input.selfPlagiarismConfig ?? DEFAULT_SELF_PLAGIARISM_CONFIG, + ); + } catch (error) { + return denyResult({ + stage: "internal_error", + reason: `self_plagiarism_calculator_error: ${error instanceof Error ? error.message : String(error)}`, + mode, + detail: detailWithReputation, + eventType: "denied", + actionClass: input.actionClass, + repoFullName: input.repoFullName, + }); + } + finalDetail = { ...detailWithReputation, selfPlagiarism }; + if (!selfPlagiarism.allowed) { + return denyResult({ + stage: "self_plagiarism", + reason: selfPlagiarism.reason, + mode, + detail: finalDetail, + eventType: selfPlagiarism.eventType, + actionClass: input.actionClass, + repoFullName: input.repoFullName, + extraPayload: { similarity: selfPlagiarism.similarity ?? null }, + }); + } + } + + return { + allowed: true, + mode, + stage: "allow", + reason: "all_governor_checks_passed", + detail: finalDetail, + ledgerEvent: { + eventType: "allowed", + repoFullName: input.repoFullName, + actionClass: input.actionClass, + decision: "allow", + reason: "all_governor_checks_passed", + payload: {}, + }, + }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 6a03d3e86b..329815ecab 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -151,6 +151,7 @@ export * from "./governor/write-rate-limit.js"; export * from "./governor/run-halt.js"; export * from "./governor/kill-switch.js"; export * from "./governor/action-mode.js"; +export * from "./governor/chokepoint.js"; export { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, diff --git a/packages/gittensory-engine/test/chokepoint.test.ts b/packages/gittensory-engine/test/chokepoint.test.ts new file mode 100644 index 0000000000..83f87b3c98 --- /dev/null +++ b/packages/gittensory-engine/test/chokepoint.test.ts @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { evaluateGovernorChokepoint, type GovernorChokepointInput } from "../dist/index.js"; + +function baseInput(overrides: Partial = {}): GovernorChokepointInput { + return { + actionClass: "open_pr", + repoFullName: "acme/widgets", + nowMs: 10_000, + wouldBeAction: { action: "open_pr", title: "Fix bug" }, + killSwitchGlobal: false, + killSwitchRepoPaused: false, + liveModeGlobalOptIn: true, + liveModeRepoOptIn: undefined, + rateLimitBuckets: { global: {}, perRepo: {} }, + rateLimitBackoffAttempts: {}, + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + ...overrides, + }; +} + +test("barrel: the public entrypoint re-exports the Governor chokepoint (#2340)", () => { + assert.equal(typeof evaluateGovernorChokepoint, "function"); +}); + +test("full allow path: live mode, every stage clear, produces an allowed verdict + allow ledger event", () => { + const decision = evaluateGovernorChokepoint(baseInput()); + assert.equal(decision.allowed, true); + assert.equal(decision.mode, "live"); + assert.equal(decision.stage, "allow"); + assert.equal(decision.ledgerEvent.eventType, "allowed"); + assert.equal(decision.ledgerEvent.decision, "allow"); +}); + +test("kill-switch (global) wins even with a live-mode opt-in present", () => { + const decision = evaluateGovernorChokepoint(baseInput({ killSwitchGlobal: true })); + assert.equal(decision.allowed, false); + assert.equal(decision.mode, "paused"); + assert.equal(decision.stage, "kill_switch"); + assert.equal(decision.ledgerEvent.eventType, "kill_switch"); + assert.equal(decision.detail.rateLimit, undefined, "later stages must not have been evaluated"); +}); + +test("kill-switch (per-repo) halts even when the global switch is inactive", () => { + const decision = evaluateGovernorChokepoint(baseInput({ killSwitchRepoPaused: true })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "kill_switch"); +}); + +test("dry-run: no live-mode opt-in anywhere shadow-logs the would-be action before any resource stage runs", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ liveModeGlobalOptIn: false, liveModeRepoOptIn: undefined }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.mode, "dry_run"); + assert.equal(decision.stage, "dry_run"); + assert.equal(decision.ledgerEvent.decision, "dry_run"); + assert.deepEqual(decision.ledgerEvent.payload, { wouldBeAction: { action: "open_pr", title: "Fix bug" } }); + assert.equal(decision.detail.rateLimit, undefined, "rate-limit must not run under dry-run"); +}); + +test("rate limit: an exhausted bucket denies before budget/convergence stages run", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + rateLimitPolicies: { + global: { open_pr: { limit: 0, windowMs: 60_000 } }, + perRepo: { open_pr: { limit: 5, windowMs: 60_000 } }, + backoffBaseMs: 100, + }, + }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "rate_limit"); + assert.equal(decision.ledgerEvent.eventType, "throttled"); + assert.equal(decision.detail.budgetCap, undefined, "budget-cap must not run once rate-limit denies"); +}); + +test("budget cap: an exceeded budget denies before non-convergence runs", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ capUsage: { budgetSpent: 100, turnsTaken: 0, elapsedMs: 0 }, capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 } }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "budget_cap"); + assert.equal(decision.ledgerEvent.eventType, "denied"); + assert.equal(decision.detail.convergence, undefined, "non-convergence must not run once budget-cap denies"); +}); + +test("budget cap: the termination ceiling denies with a kill_switch eventType (hard wall-clock stop)", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 2_000_000 }, capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 } }), + ); + assert.equal(decision.stage, "budget_cap"); + assert.equal(decision.ledgerEvent.eventType, "kill_switch"); +}); + +test("non-convergence: a stuck item denies before reputation/self-plagiarism run", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ convergenceInput: { attempts: 5, consecutiveFailures: 5, reenqueues: 0, reachedDone: false } }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "non_convergence"); + assert.equal(decision.detail.reputation, undefined); +}); + +test("reputation throttle: a degraded track record denies open_pr before self-plagiarism runs", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ reputationHistory: { decided: 10, unfavorable: 8 } }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "reputation_throttle"); + assert.equal(decision.ledgerEvent.eventType, "throttled"); + assert.equal(decision.detail.selfPlagiarism, undefined); +}); + +test("reputation throttle: insufficient history fails OPEN (not evidence of a problem) and reaches allow", () => { + const decision = evaluateGovernorChokepoint(baseInput({ reputationHistory: { decided: 1, unfavorable: 1 } })); + assert.equal(decision.allowed, true); + assert.equal(decision.detail.reputation?.reason, "insufficient_history"); +}); + +test("self-plagiarism: a losing near-duplicate claim denies open_pr", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + selfPlagiarismCandidate: { repoFullName: "acme/widgets", fingerprint: "fix auth bug login", submittedAt: "2026-07-11T12:00:00Z" }, + selfPlagiarismRecentSubmissions: [ + { repoFullName: "acme/widgets", fingerprint: "fix auth bug login", submittedAt: "2026-07-10T12:00:00Z", pullRequestNumber: 42 }, + ], + }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "self_plagiarism"); + assert.equal(decision.ledgerEvent.eventType, "throttled"); +}); + +test("self-plagiarism and reputation are skipped entirely for a non-open_pr action, even with denying inputs", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + actionClass: "apply_labels", + wouldBeAction: { action: "apply_labels", labels: ["bug"] }, + reputationHistory: { decided: 10, unfavorable: 10 }, + selfPlagiarismCandidate: { repoFullName: "acme/widgets", fingerprint: "x", submittedAt: "2026-07-11T12:00:00Z" }, + selfPlagiarismRecentSubmissions: [{ repoFullName: "acme/widgets", fingerprint: "x", submittedAt: "2026-07-10T12:00:00Z" }], + }), + ); + assert.equal(decision.allowed, true, "non-open_pr actions must not be gated by submission-specific stages"); + assert.equal(decision.detail.reputation, undefined); + assert.equal(decision.detail.selfPlagiarism, undefined); +}); + +test("fail-closed: a rate-limit calculator error denies with stage internal_error, never falls through to allow", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ rateLimitBuckets: null as unknown as GovernorChokepointInput["rateLimitBuckets"] }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /rate_limit_calculator_error/); +}); + +test("fail-closed: a budget-cap calculator error denies with stage internal_error", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ capUsage: null as unknown as GovernorChokepointInput["capUsage"] }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /budget_cap_calculator_error/); +}); + +test("fail-closed: a non-convergence calculator error denies with stage internal_error", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ convergenceInput: null as unknown as GovernorChokepointInput["convergenceInput"] }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /non_convergence_calculator_error/); +}); + +test("fail-closed: a reputation-throttle calculator error denies rather than silently skipping the stage", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ reputationHistory: null as unknown as GovernorChokepointInput["reputationHistory"] }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /reputation_throttle_calculator_error/); +}); + +test("fail-closed: a self-plagiarism calculator error denies rather than silently skipping the stage", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ selfPlagiarismCandidate: null as unknown as GovernorChokepointInput["selfPlagiarismCandidate"] }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /self_plagiarism_calculator_error/); +}); + +test("the repo-side live opt-in alone (no global env opt-in) is sufficient to reach the resource stages", () => { + const decision = evaluateGovernorChokepoint(baseInput({ liveModeGlobalOptIn: false, liveModeRepoOptIn: "live" })); + assert.equal(decision.mode, "live"); + assert.equal(decision.allowed, true); +}); diff --git a/packages/gittensory-miner/lib/governor-chokepoint.d.ts b/packages/gittensory-miner/lib/governor-chokepoint.d.ts new file mode 100644 index 0000000000..60bee9a8eb --- /dev/null +++ b/packages/gittensory-miner/lib/governor-chokepoint.d.ts @@ -0,0 +1,14 @@ +import type { GovernorChokepointInput, GovernorDecision, WriteRateLimitBackoffStore, WriteRateLimitBucketStore } from "@jsonbored/gittensory-engine"; +import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js"; + +export type EvaluateGovernorChokepointGateResult = { + decision: GovernorDecision; + recorded: GovernorLedgerEntry; + rateLimitBuckets: WriteRateLimitBucketStore; + rateLimitBackoffAttempts: WriteRateLimitBackoffStore; +}; + +export function evaluateGovernorChokepointGate( + input: GovernorChokepointInput, + options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry }, +): EvaluateGovernorChokepointGateResult; diff --git a/packages/gittensory-miner/lib/governor-chokepoint.js b/packages/gittensory-miner/lib/governor-chokepoint.js new file mode 100644 index 0000000000..bfc53eb3b8 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-chokepoint.js @@ -0,0 +1,52 @@ +// The Governor chokepoint gate (#2340). Wraps the pure `evaluateGovernorChokepoint` engine decision with the +// two stateful side effects every caller needs: persisting the resulting ledger event, and (only when the +// rate-limit stage actually ran) advancing/backing-off the rate-limit bucket state. This is the ONLY sanctioned +// call site a real write action (open_pr, file_issue, apply_labels, post_eligibility_comment, create_branch, +// delete_branch, generate_tests) should be gated through. + +import { + clearWriteRateLimitBackoff, + evaluateGovernorChokepoint, + recordWriteRateLimitAllowed, + recordWriteRateLimitDenied, +} from "@jsonbored/gittensory-engine"; +import { appendGovernorEvent } from "./governor-ledger.js"; + +/** + * Evaluate a write action against the full Governor precedence ladder, persist the resulting ledger event, and + * advance rate-limit bucket/backoff state when the rate-limit stage actually ran (kill-switch and dry-run + * short-circuit before rate-limit is evaluated, so bucket state is untouched in those cases). + * + * @param {import("@jsonbored/gittensory-engine").GovernorChokepointInput} input + * @param {{ append?: typeof appendGovernorEvent }} [options] + * @returns {{ + * decision: import("@jsonbored/gittensory-engine").GovernorDecision, + * recorded: import("./governor-ledger.js").GovernorLedgerEntry, + * rateLimitBuckets: import("@jsonbored/gittensory-engine").WriteRateLimitBucketStore, + * rateLimitBackoffAttempts: import("@jsonbored/gittensory-engine").WriteRateLimitBackoffStore, + * }} + */ +export function evaluateGovernorChokepointGate(input, options = {}) { + const append = options.append ?? appendGovernorEvent; + const decision = evaluateGovernorChokepoint(input); + const recorded = append(decision.ledgerEvent); + + let rateLimitBuckets = input.rateLimitBuckets; + let rateLimitBackoffAttempts = input.rateLimitBackoffAttempts; + if (decision.detail.rateLimit) { + if (decision.detail.rateLimit.allowed) { + rateLimitBuckets = recordWriteRateLimitAllowed( + input.rateLimitBuckets, + input.actionClass, + input.repoFullName, + input.nowMs, + input.rateLimitPolicies, + ); + rateLimitBackoffAttempts = clearWriteRateLimitBackoff(input.rateLimitBackoffAttempts, input.actionClass, input.repoFullName); + } else { + rateLimitBackoffAttempts = recordWriteRateLimitDenied(input.rateLimitBackoffAttempts, input.actionClass, input.repoFullName); + } + } + + return { decision, recorded, rateLimitBuckets, rateLimitBackoffAttempts }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 643dac367e..879d5ace23 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/governor-kill-switch.js && node --check lib/governor-action-mode.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/governor-kill-switch.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-governor-chokepoint.test.ts b/test/unit/miner-governor-chokepoint.test.ts new file mode 100644 index 0000000000..e42716ad60 --- /dev/null +++ b/test/unit/miner-governor-chokepoint.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { evaluateGovernorChokepointGate } from "../../packages/gittensory-miner/lib/governor-chokepoint.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +function baseInput(overrides: Record = {}) { + return { + actionClass: "open_pr", + repoFullName: "acme/widgets", + nowMs: 10_000, + wouldBeAction: { action: "open_pr", title: "Fix bug" }, + killSwitchGlobal: false, + killSwitchRepoPaused: false, + liveModeGlobalOptIn: true, + liveModeRepoOptIn: undefined, + rateLimitBuckets: { global: {}, perRepo: {} }, + rateLimitBackoffAttempts: {}, + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + ...overrides, + }; +} + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function openLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-chokepoint-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + return ledger; +} + +describe("evaluateGovernorChokepointGate (#2340)", () => { + it("records an allow decision to the ledger and advances the rate-limit bucket", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput(), { append: (event) => ledger.appendGovernorEvent(event) }); + + expect(result.decision.allowed).toBe(true); + expect(result.recorded.eventType).toBe("allowed"); + expect(result.rateLimitBuckets.global.open_pr?.count).toBe(1); + expect(ledger.readGovernorEvents({ repoFullName: "acme/widgets" })).toHaveLength(1); + }); + + it("a kill-switch denial records to the ledger and leaves rate-limit bucket state untouched", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ killSwitchGlobal: true }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("kill_switch"); + expect(result.recorded.eventType).toBe("kill_switch"); + expect(result.rateLimitBuckets).toEqual({ global: {}, perRepo: {} }); + }); + + it("dry-run shadow-logs without touching rate-limit bucket state", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ liveModeGlobalOptIn: false }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.mode).toBe("dry_run"); + expect(result.recorded.decision).toBe("dry_run"); + expect(result.rateLimitBuckets).toEqual({ global: {}, perRepo: {} }); + }); + + it("a rate-limit denial bumps backoff attempts without advancing the bucket count", () => { + const ledger = openLedger(); + const policies = { + global: { open_pr: { limit: 0, windowMs: 60_000 } }, + perRepo: { open_pr: { limit: 5, windowMs: 60_000 } }, + backoffBaseMs: 100, + }; + const result = evaluateGovernorChokepointGate(baseInput({ rateLimitPolicies: policies }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("rate_limit"); + expect(result.recorded.eventType).toBe("throttled"); + expect(result.rateLimitBackoffAttempts["open_pr:acme/widgets"]).toBe(1); + }); +});