Skip to content

Commit b0b2d69

Browse files
committed
feat(miner-governor): fail-closed chokepoint composing every write calculator (#2340) (#5018)
The single decision point every miner write action (open_pr, file_issue, apply_labels, post_eligibility_comment, create_branch, delete_branch, generate_tests) must pass through. Composes the kill-switch (#2341) and dry-run-default (#2342) primitives with the previously-shipped pure calculators -- rate-limit (#2344), budget/turn/termination caps, non-convergence detection, self-reputation throttle, and self-plagiarism -- into one precedence ladder: global kill-switch > per-repo pause > dry-run > rate-limit > budget cap > non-convergence > reputation throttle > self-plagiarism > allow. Reputation-throttle and self-plagiarism extend beyond the issue's three explicitly-named calculators, per those two modules' own doc comments forward-referencing this exact chokepoint; both reuse their own already-reviewed boolean gate semantic (throttled/allowed) rather than inventing new policy. Both apply only to actionClass "open_pr" (their own ledger builders are submission-scoped). Any calculator that throws denies immediately with stage "internal_error", never falls through to allow. Pure engine module (no IO); the miner-lib wrapper owns persisting the ledger event and advancing rate-limit bucket state only when the rate-limit stage actually ran. Stacked on #2341 + #2342 (imports from both).
1 parent 6257ed8 commit b0b2d69

7 files changed

Lines changed: 732 additions & 1 deletion

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
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+
}

packages/gittensory-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export * from "./governor/write-rate-limit.js";
151151
export * from "./governor/run-halt.js";
152152
export * from "./governor/kill-switch.js";
153153
export * from "./governor/action-mode.js";
154+
export * from "./governor/chokepoint.js";
154155
export {
155156
GOVERNOR_LEDGER_EVENT_TYPES,
156157
normalizeGovernorLedgerEvent,

0 commit comments

Comments
 (0)