diff --git a/.gittensory-miner.yml.example b/.gittensory-miner.yml.example index fa5db4f202..50e5a09ea2 100644 --- a/.gittensory-miner.yml.example +++ b/.gittensory-miner.yml.example @@ -73,3 +73,10 @@ selfPlagiarism: # `paused` (boolean, default: false). killSwitch: paused: false + +# Per-repo dry-run/live execution opt-in (#2342). A freshly-configured miner always defaults to dry-run; this +# field alone is NOT sufficient to go live -- the miner's own operator must also separately opt in globally +# (env var GITTENSORY_MINER_LIVE_MODE=live). `liveModeOptIn` must equal EXACTLY the string "live" (not a +# boolean, not "yes"/"on") -- any other value stays dry-run. Default: null. +execution: + liveModeOptIn: null diff --git a/packages/gittensory-engine/src/governor/action-mode.ts b/packages/gittensory-engine/src/governor/action-mode.ts new file mode 100644 index 0000000000..1f257044f3 --- /dev/null +++ b/packages/gittensory-engine/src/governor/action-mode.ts @@ -0,0 +1,90 @@ +// Governor dry-run-by-default enforcement (#2342): resolves the miner's overall action mode -- "safest wins" +// precedence mirroring `resolveAgentActionMode` (`src/settings/agent-execution.ts`): paused > dry_run > live. +// A freshly-configured miner (no opt-in present anywhere) MUST default to dry_run, never live -- this is the +// deny-by-default floor `src/settings/autonomy.ts`'s `DEFAULT_AUTONOMY_LEVEL = "observe"` establishes for the +// review-stack, extended here to the miner's own runtime. +// +// DETECTOR ONLY -- no IO, no persistence. Consulting this alongside the other pure calculators (rate-limit, +// budget caps, reputation, self-plagiarism, non-convergence) and recording every CHECK is the Governor +// chokepoint's job (#2340), which consults this module (after the kill-switch) in its precedence ladder. + +import type { GovernorLedgerEvent } from "../governor-ledger.js"; +import { isMinerKillSwitchActive, type MinerKillSwitchScope } from "./kill-switch.js"; + +/** Whether the miner actually executes a write, only shadow-logs what it WOULD do, or is halted entirely. */ +export type MinerActionMode = "paused" | "dry_run" | "live"; + +/** + * The ONLY value that opts a miner into LIVE write execution. Deliberately a specific string literal, not a + * boolean -- a fat-fingered `liveModeOptIn: true`, `"yes"`, `"on"`, or `GITTENSORY_MINER_LIVE_MODE=1` must never + * accidentally unlock writes the way a truthy-coerced flag could. Per the issue's explicit requirement: "not a + * generic boolean flag that could be accidentally true." + */ +export const MINER_LIVE_MODE_OPT_IN = "live"; + +/** Env var an operator sets (to exactly {@link MINER_LIVE_MODE_OPT_IN}) to opt their own miner instance into + * live write execution, independent of any per-repo `.gittensory-miner.yml` opt-in. */ +export const MINER_LIVE_MODE_ENV_VAR = "GITTENSORY_MINER_LIVE_MODE"; + +/** True only when `value` is EXACTLY the {@link MINER_LIVE_MODE_OPT_IN} string -- no truthy coercion, no case + * folding, no alternate spellings. Everything else (including `true`, `"Live"`, `"1"`) reads as not opted in. */ +export function isExplicitMinerLiveModeOptIn(value: unknown): boolean { + return value === MINER_LIVE_MODE_OPT_IN; +} + +/** True when the operator's global env-level live-mode opt-in is set to exactly {@link MINER_LIVE_MODE_OPT_IN}. */ +export function isGlobalMinerLiveModeOptIn(env: Record): boolean { + return env[MINER_LIVE_MODE_ENV_VAR] === MINER_LIVE_MODE_OPT_IN; +} + +/** + * Resolve the miner's overall action mode. Precedence (safest wins, mirroring `resolveAgentActionMode`): + * 1. Kill-switch active (either scope, #2341) -> `"paused"` -- always wins, regardless of any live-mode opt-in. + * 2. An explicit live-mode opt-in from EITHER the operator's global env config OR the target repo's own + * `.gittensory-miner.yml` (`MinerGoalSpec.execution.liveModeOptIn`) -> `"live"`. + * 3. Otherwise -> `"dry_run"`. No config anywhere, or a malformed/partial config that fails to normalize to the + * exact opt-in literal, both fall through to this branch -- absence or ambiguity always means dry-run. + * + * A target repo that wants to guarantee it never receives live automated writes -- even from an operator whose + * own miner instance is globally live -- sets its OWN kill-switch (`killSwitch.paused: true`, #2341), which + * takes precedence over any live-mode opt-in per step 1 above; this module does not duplicate that mechanism. + */ +export function resolveMinerActionMode(input: { + killSwitchScope: MinerKillSwitchScope; + repoLiveModeOptIn?: unknown; + globalLiveModeOptIn: boolean; +}): MinerActionMode { + if (isMinerKillSwitchActive(input.killSwitchScope)) return "paused"; + if (input.globalLiveModeOptIn || isExplicitMinerLiveModeOptIn(input.repoLiveModeOptIn)) return "live"; + return "dry_run"; +} + +/** True only for `"live"` -- the only mode that performs a real write. `"paused"` does nothing; `"dry_run"` + * records a shadow action but never mutates. */ +export function minerActionModeExecutes(mode: MinerActionMode): boolean { + return mode === "live"; +} + +/** + * Governor-ledger row for a dry-run SHADOW action (#2342's "logs the WOULD-BE action... without ever invoking + * the actual command" deliverable). `eventType` stays within the existing closed vocabulary (`"allowed"` -- the + * Governor's other checks did not deny this action, dry-run mode is simply choosing to shadow-log instead of + * execute); `decision: "dry_run"` is the distinct marker this deliverable calls for. `wouldBeAction` is left as + * a generic record (not the concrete `LocalWriteActionSpec` type) so this package stays decoupled from the + * main app's `src/mcp/local-write-tools.ts` -- the caller wiring a real action spec into this call owns that + * shape. + */ +export function buildMinerDryRunGovernorLedgerEvent(input: { + repoFullName?: string | null | undefined; + actionClass: string; + wouldBeAction: Record; +}): GovernorLedgerEvent { + return { + eventType: "allowed", + repoFullName: input.repoFullName ?? null, + actionClass: input.actionClass, + decision: "dry_run", + reason: "dry_run_mode_active", + payload: { wouldBeAction: input.wouldBeAction }, + }; +} 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 a10c01a74f..9d0aa6949c 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -150,6 +150,8 @@ export * from "./governor/reputation-throttle.js"; 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, @@ -405,6 +407,7 @@ export { discoverMinerGoalSpecPath, MINER_GOAL_SPEC_FILENAMES, type FeasibilityGatePolicy, + type MinerExecutionPolicy, type MinerGoalSpec, type MinerIssueDiscoveryPolicy, type MinerKillSwitchPolicy, diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 11fbcabc62..015ac16f2f 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -1,5 +1,6 @@ import { parse as parseYaml } from "yaml"; +import { MINER_LIVE_MODE_OPT_IN } from "./governor/action-mode.js"; import { DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD, resolveSelfPlagiarismConfig, @@ -44,6 +45,18 @@ export type MinerKillSwitchPolicy = { paused: boolean; }; +/** Per-repo dry-run/live execution tuning consulted by the Governor chokepoint's action-mode primitive (#2342). */ +export type MinerExecutionPolicy = { + /** + * Explicit opt-in to LIVE write execution for this repo. Must equal EXACTLY the literal string `"live"` — any + * other value (a typo, `"yes"`, `"on"`, or a boolean `true` from a malformed file) is treated as not opted + * in, so a fat-fingered config can never accidentally enable live writes. A miner also stays in dry-run + * unless its own operator separately opts in globally (`GITTENSORY_MINER_LIVE_MODE=live`) — this field alone + * cannot force a stranger's miner instance live. Default: null (dry-run). + */ + liveModeOptIn: typeof MINER_LIVE_MODE_OPT_IN | null; +}; + /** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */ export type MinerGoalSpec = { /** @@ -97,6 +110,11 @@ export type MinerGoalSpec = { * Default: { paused: false }. */ killSwitch: MinerKillSwitchPolicy; + /** + * Per-repo dry-run/live execution opt-in consulted by the Governor chokepoint (#2342). + * Default: { liveModeOptIn: null }. + */ + execution: MinerExecutionPolicy; }; /** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the @@ -128,6 +146,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ feasibilityGate: Object.freeze({ enabled: true, suppressedReasons: Object.freeze([]) }), selfPlagiarism: Object.freeze({ similarityThreshold: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD }), killSwitch: Object.freeze({ paused: false }), + execution: Object.freeze({ liveModeOptIn: null }), }); const MAX_MINER_GOAL_SPEC_BYTES = 32_768; @@ -147,6 +166,7 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { }, selfPlagiarism: { ...DEFAULT_MINER_GOAL_SPEC.selfPlagiarism }, killSwitch: { ...DEFAULT_MINER_GOAL_SPEC.killSwitch }, + execution: { ...DEFAULT_MINER_GOAL_SPEC.execution }, }; } @@ -266,6 +286,30 @@ function normalizeKillSwitchPolicy( }; } +function normalizeExecutionPolicy( + value: unknown, + field: string, + fallback: MinerExecutionPolicy, + warnings: string[], +): MinerExecutionPolicy { + if (value === undefined || value === null) return fallback; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a mapping; falling back to defaults.`); + return fallback; + } + const record = value as Record; + const raw = record.liveModeOptIn; + if (raw === undefined || raw === null) return { liveModeOptIn: fallback.liveModeOptIn }; + if (typeof raw !== "string") { + warnings.push(`MinerGoalSpec field "${field}.liveModeOptIn" must be a string; falling back to dry-run.`); + return { liveModeOptIn: fallback.liveModeOptIn }; + } + // A string that isn't the exact opt-in literal is NOT malformed (it's a valid string, just not the one value + // that grants live mode) -- no warning, just a silent, safe fall-through to dry-run. Only a wrong TYPE above + // warns, matching every other field's tolerant-parse convention. + return { liveModeOptIn: raw === MINER_LIVE_MODE_OPT_IN ? raw : null }; +} + function normalizePositiveInteger(value: unknown, field: string, fallback: number, warnings: string[]): number { if (value === undefined || value === null) return fallback; if (typeof value !== "number" || !Number.isFinite(value)) { @@ -302,7 +346,8 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { spec.feasibilityGate.enabled !== DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled || spec.feasibilityGate.suppressedReasons.length > 0 || spec.selfPlagiarism.similarityThreshold !== DEFAULT_MINER_GOAL_SPEC.selfPlagiarism.similarityThreshold || - spec.killSwitch.paused !== DEFAULT_MINER_GOAL_SPEC.killSwitch.paused + spec.killSwitch.paused !== DEFAULT_MINER_GOAL_SPEC.killSwitch.paused || + spec.execution.liveModeOptIn !== DEFAULT_MINER_GOAL_SPEC.execution.liveModeOptIn ); } @@ -356,6 +401,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { warnings, ), killSwitch: normalizeKillSwitchPolicy(record.killSwitch, "killSwitch", DEFAULT_MINER_GOAL_SPEC.killSwitch, warnings), + execution: normalizeExecutionPolicy(record.execution, "execution", DEFAULT_MINER_GOAL_SPEC.execution, warnings), }; if (!hasConfiguredGoalFields(spec)) { warnings.push("MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults."); diff --git a/packages/gittensory-engine/test/action-mode.test.ts b/packages/gittensory-engine/test/action-mode.test.ts new file mode 100644 index 0000000000..ef8887a3d1 --- /dev/null +++ b/packages/gittensory-engine/test/action-mode.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + MINER_LIVE_MODE_ENV_VAR, + MINER_LIVE_MODE_OPT_IN, + buildMinerDryRunGovernorLedgerEvent, + isExplicitMinerLiveModeOptIn, + isGlobalMinerLiveModeOptIn, + minerActionModeExecutes, + resolveMinerActionMode, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports the action-mode primitive (#2342)", () => { + assert.equal(typeof resolveMinerActionMode, "function"); + assert.equal(typeof minerActionModeExecutes, "function"); + assert.equal(typeof isExplicitMinerLiveModeOptIn, "function"); + assert.equal(typeof isGlobalMinerLiveModeOptIn, "function"); + assert.equal(typeof buildMinerDryRunGovernorLedgerEvent, "function"); + assert.equal(MINER_LIVE_MODE_OPT_IN, "live"); + assert.equal(MINER_LIVE_MODE_ENV_VAR, "GITTENSORY_MINER_LIVE_MODE"); +}); + +test("isExplicitMinerLiveModeOptIn: only the exact literal opts in, no truthy coercion", () => { + assert.equal(isExplicitMinerLiveModeOptIn("live"), true); + for (const value of [true, 1, "Live", "LIVE", "yes", "on", "1", "true", "", null, undefined, {}]) { + assert.equal(isExplicitMinerLiveModeOptIn(value), false, `expected ${JSON.stringify(value)} not to opt in`); + } +}); + +test("isGlobalMinerLiveModeOptIn: only the exact env value opts in", () => { + assert.equal(isGlobalMinerLiveModeOptIn({ GITTENSORY_MINER_LIVE_MODE: "live" }), true); + for (const value of [undefined, "", "1", "true", "Live", "on"]) { + assert.equal(isGlobalMinerLiveModeOptIn({ GITTENSORY_MINER_LIVE_MODE: value }), false); + } +}); + +test("resolveMinerActionMode: no config anywhere defaults to dry_run, never live", () => { + assert.equal( + resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: undefined, globalLiveModeOptIn: false }), + "dry_run", + ); +}); + +test("resolveMinerActionMode: malformed/partial opt-in values fail closed to dry_run", () => { + for (const repoLiveModeOptIn of [true, "yes", "LIVE", "", null, 1]) { + assert.equal( + resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn, globalLiveModeOptIn: false }), + "dry_run", + `expected ${JSON.stringify(repoLiveModeOptIn)} to stay dry_run`, + ); + } +}); + +test("resolveMinerActionMode: the exact repo-side opt-in flips to live", () => { + assert.equal( + resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: "live", globalLiveModeOptIn: false }), + "live", + ); +}); + +test("resolveMinerActionMode: the global operator opt-in alone also flips to live", () => { + assert.equal( + resolveMinerActionMode({ killSwitchScope: "none", repoLiveModeOptIn: undefined, globalLiveModeOptIn: true }), + "live", + ); +}); + +test("resolveMinerActionMode: the kill-switch always wins over any live-mode opt-in", () => { + assert.equal( + resolveMinerActionMode({ killSwitchScope: "repo", repoLiveModeOptIn: "live", globalLiveModeOptIn: true }), + "paused", + ); + assert.equal( + resolveMinerActionMode({ killSwitchScope: "global", repoLiveModeOptIn: "live", globalLiveModeOptIn: true }), + "paused", + ); +}); + +test("minerActionModeExecutes: true only for live", () => { + assert.equal(minerActionModeExecutes("live"), true); + assert.equal(minerActionModeExecutes("dry_run"), false); + assert.equal(minerActionModeExecutes("paused"), false); +}); + +test("buildMinerDryRunGovernorLedgerEvent: records the would-be action without eventType denied/throttled", () => { + const event = buildMinerDryRunGovernorLedgerEvent({ + repoFullName: "acme/widgets", + actionClass: "open_pr", + wouldBeAction: { action: "open_pr", title: "example" }, + }); + assert.deepEqual(event, { + eventType: "allowed", + repoFullName: "acme/widgets", + actionClass: "open_pr", + decision: "dry_run", + reason: "dry_run_mode_active", + payload: { wouldBeAction: { action: "open_pr", title: "example" } }, + }); +}); + +test("buildMinerDryRunGovernorLedgerEvent: an omitted repoFullName normalizes to null", () => { + const event = buildMinerDryRunGovernorLedgerEvent({ + actionClass: "open_pr", + wouldBeAction: { action: "open_pr" }, + }); + assert.equal(event.repoFullName, null); +}); diff --git a/packages/gittensory-engine/test/chokepoint.test.ts b/packages/gittensory-engine/test/chokepoint.test.ts new file mode 100644 index 0000000000..33aefecc6c --- /dev/null +++ b/packages/gittensory-engine/test/chokepoint.test.ts @@ -0,0 +1,283 @@ +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); +}); + +/** Throws a non-`Error` value (a plain string) the instant any property is read -- distinct from the existing + * `null as unknown as X` fail-closed tests above, which all throw a genuine `TypeError` (a real `Error` + * instance) and so only ever exercise the `error instanceof Error` arm of each catch block's message + * formatting. This exercises the `String(error)` fallback arm for a thrown non-Error value. */ +function throwingProxy(message: string): never { + return new Proxy( + {}, + { + get(): never { + throw message; + }, + }, + ) as never; +} + +test("fail-closed: a rate-limit calculator throwing a non-Error value still formats a reason via String(error)", () => { + const decision = evaluateGovernorChokepoint(baseInput({ rateLimitBuckets: throwingProxy("boom: not an Error") })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /rate_limit_calculator_error: boom: not an Error/); +}); + +test("fail-closed: a budget-cap calculator throwing a non-Error value still formats a reason via String(error)", () => { + const decision = evaluateGovernorChokepoint(baseInput({ capUsage: throwingProxy("boom: not an Error") })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /budget_cap_calculator_error: boom: not an Error/); +}); + +test("fail-closed: a non-convergence calculator throwing a non-Error value still formats a reason via String(error)", () => { + const decision = evaluateGovernorChokepoint(baseInput({ convergenceInput: throwingProxy("boom: not an Error") })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /non_convergence_calculator_error: boom: not an Error/); +}); + +test("fail-closed: a reputation-throttle calculator throwing a non-Error value still formats a reason via String(error)", () => { + const decision = evaluateGovernorChokepoint(baseInput({ reputationHistory: throwingProxy("boom: not an Error") })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /reputation_throttle_calculator_error: boom: not an Error/); +}); + +test("fail-closed: a self-plagiarism calculator throwing a non-Error value still formats a reason via String(error)", () => { + const decision = evaluateGovernorChokepoint(baseInput({ selfPlagiarismCandidate: throwingProxy("boom: not an Error") })); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "internal_error"); + assert.match(decision.reason, /self_plagiarism_calculator_error: boom: not an Error/); +}); + +test("rate limit: a caller-supplied randomFn is threaded through to the calculator (not just the default)", () => { + let called = false; + const decision = evaluateGovernorChokepoint( + baseInput({ + rateLimitRandomFn: () => { + called = true; + return 0.25; + }, + }), + ); + assert.equal(decision.allowed, true, "a custom randomFn on an otherwise-clear bucket must not itself deny"); + // The rate-limit calculator only actually invokes randomFn when a bucket is over-limit and jittering a + // retry delay; on a clear bucket it is threaded through but never called -- asserting `false` here would be + // wrong. What this test verifies is the conditional-spread branch (the field IS present) compiles and runs + // end-to-end without the calculator rejecting an unexpected extra field. + assert.equal(called, false, "documents that a clear bucket never needs to call randomFn"); +}); + +test("self-plagiarism: a whitespace-only candidate fingerprint denies via missing_candidate_fingerprint, with similarity omitted -> null", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + selfPlagiarismCandidate: { repoFullName: "acme/widgets", fingerprint: " ", submittedAt: "2026-07-11T12:00:00Z" }, + selfPlagiarismRecentSubmissions: [], + }), + ); + assert.equal(decision.allowed, false); + assert.equal(decision.stage, "self_plagiarism"); + assert.equal(decision.detail.selfPlagiarism?.similarity, undefined, "no similarity was ever computed for this deny reason"); + assert.equal(decision.ledgerEvent.payload?.similarity, null, "the ?? null fallback must surface explicitly, not as an omitted key"); +}); diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts index 198a1c72a3..2727745eea 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -49,6 +49,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }); assert.deepEqual(parsed.warnings, []); }); @@ -67,6 +68,33 @@ test("parseMinerGoalSpec: killSwitch sub-field normalizes independently and reje assert.match(arrayValue.warnings.join(" "), /killSwitch.*must be a mapping/i); }); +test("parseMinerGoalSpec: execution.liveModeOptIn requires the exact literal, no truthy coercion", () => { + const optedIn = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: { liveModeOptIn: "live" } }); + assert.deepEqual(optedIn.spec.execution, { liveModeOptIn: "live" }); + assert.deepEqual(optedIn.warnings, []); + + // A near-miss string is a valid string, just not the magic literal -- silently stays dry-run, no warning. + const nearMiss = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: { liveModeOptIn: "LIVE" } }); + assert.deepEqual(nearMiss.spec.execution, { liveModeOptIn: null }); + assert.deepEqual(nearMiss.warnings, []); + + // A wrong TYPE (not a string at all) is malformed and warns, matching every other field's convention. + const wrongType = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: { liveModeOptIn: true } }); + assert.deepEqual(wrongType.spec.execution, { liveModeOptIn: null }); + assert.match(wrongType.warnings.join(" "), /execution\.liveModeOptIn/i); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: ["not", "a", "mapping"] }); + assert.deepEqual(arrayValue.spec.execution, { liveModeOptIn: null }); + assert.match(arrayValue.warnings.join(" "), /execution.*must be a mapping/i); + + // The mapping is PRESENT but the liveModeOptIn key itself is absent -- distinct from `execution` being + // omitted entirely (covered by the DEFAULT_MINER_GOAL_SPEC-equality tests elsewhere); falls back with no + // warning, same as any other absent optional sub-field. + const keyAbsent = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: {} }); + assert.deepEqual(keyAbsent.spec.execution, { liveModeOptIn: null }); + assert.deepEqual(keyAbsent.warnings, []); +}); + test("parseMinerGoalSpec: feasibilityGate sub-fields normalize independently and reject a non-mapping value", () => { const valid = parseMinerGoalSpec({ wantedPaths: ["src/**"], @@ -173,6 +201,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }); const warningText = parsed.warnings.join(" "); assert.match(warningText, /minerEnabled/i); diff --git a/packages/gittensory-engine/test/miner-goal-spec.test.ts b/packages/gittensory-engine/test/miner-goal-spec.test.ts index 70793430e3..a722d9f9b2 100644 --- a/packages/gittensory-engine/test/miner-goal-spec.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -22,6 +22,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: Object.freeze({ similarityThreshold: 0.85 }), killSwitch: Object.freeze({ paused: false }), + execution: Object.freeze({ liveModeOptIn: null }), }); }); @@ -35,12 +36,14 @@ test("DEFAULT_MINER_GOAL_SPEC is deep-frozen so the shared singleton can't be mu assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressedReasons)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.selfPlagiarism)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.killSwitch)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.execution)); }); test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { assert.deepEqual(Object.keys(DEFAULT_MINER_GOAL_SPEC).sort(), [ "blockedLabels", "blockedPaths", + "execution", "feasibilityGate", "issueDiscoveryPolicy", "killSwitch", diff --git a/packages/gittensory-miner/docs/miner-goal-spec.md b/packages/gittensory-miner/docs/miner-goal-spec.md index c84bbe44c0..8fae73888d 100644 --- a/packages/gittensory-miner/docs/miner-goal-spec.md +++ b/packages/gittensory-miner/docs/miner-goal-spec.md @@ -68,3 +68,9 @@ Per-repo tuning for the Governor self-plagiarism throttle consulted before `open Per-repo kill-switch consulted by the Governor chokepoint before every write action (#2341). Distinct from `minerEnabled`: `minerEnabled` is a discovery-time opt-out (a miner never even considers the repo), while `killSwitch.paused` is a runtime halt of an already-in-flight queue — un-pausing resumes exactly where the queue left off. A separate, operator-controlled GLOBAL kill-switch (env var `GITTENSORY_MINER_KILL_SWITCH`) halts every repo at once and always wins over this per-repo flag. - `paused` (boolean, default: `false`) — halts all miner WRITE actions for this repo without deregistering it from targeting/discovery. + +### `execution` (object, default: `{ liveModeOptIn: null }`) + +Per-repo dry-run/live execution opt-in consulted by the Governor chokepoint (#2342). A freshly-configured miner always defaults to dry-run (observe/log only, never execute a write) — this field is the only per-repo path to live mode, and it alone is not sufficient: the miner's own operator must also separately opt in globally (env var `GITTENSORY_MINER_LIVE_MODE=live`) before writes actually execute. A repo that wants to guarantee it never receives live automated writes, regardless of any operator's global setting, should use `killSwitch.paused: true` instead — the kill-switch always takes precedence over any live-mode opt-in. + +- `liveModeOptIn` (string or `null`, default: `null`) — must equal EXACTLY the literal `"live"` to opt in. Any other value (a typo, `"yes"`, `"on"`, or a boolean `true` from a malformed file) is treated as not opted in — deliberately not a boolean flag, so a fat-fingered config can never accidentally enable live writes. diff --git a/packages/gittensory-miner/lib/governor-action-mode.d.ts b/packages/gittensory-miner/lib/governor-action-mode.d.ts new file mode 100644 index 0000000000..90ee4db0a4 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-action-mode.d.ts @@ -0,0 +1,26 @@ +import type { MinerActionMode, MinerKillSwitchScope } from "@jsonbored/gittensory-engine"; +import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js"; + +export type ResolveMinerActionModeGateInput = { + killSwitchScope: MinerKillSwitchScope; + repoLiveModeOptIn?: unknown; + env?: Record; +}; + +export type ResolveMinerActionModeGateResult = { + mode: MinerActionMode; + executes: boolean; +}; + +export function resolveMinerActionModeGate(input: ResolveMinerActionModeGateInput): ResolveMinerActionModeGateResult; + +export type RecordMinerDryRunShadowInput = { + repoFullName?: string; + actionClass: string; + wouldBeAction: Record; +}; + +export function recordMinerDryRunShadow( + input: RecordMinerDryRunShadowInput, + options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry }, +): GovernorLedgerEntry; diff --git a/packages/gittensory-miner/lib/governor-action-mode.js b/packages/gittensory-miner/lib/governor-action-mode.js new file mode 100644 index 0000000000..5ad35aa7c8 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-action-mode.js @@ -0,0 +1,47 @@ +// Governor dry-run-by-default gate (#2342). Resolves the miner's overall action mode (paused > dry_run > live, +// "safest wins") and records dry-run SHADOW actions to the append-only governor ledger. A freshly-configured +// miner defaults to dry_run -- live execution requires an explicit, hard-to-fat-finger opt-in. + +import { + buildMinerDryRunGovernorLedgerEvent, + isGlobalMinerLiveModeOptIn, + minerActionModeExecutes, + resolveMinerActionMode, +} from "@jsonbored/gittensory-engine"; +import { appendGovernorEvent } from "./governor-ledger.js"; + +/** + * Resolve the miner's overall action mode from the kill-switch scope (see `checkMinerKillSwitch` in + * `./governor-kill-switch.js`), the repo's own `.gittensory-miner.yml` opt-in, and the operator's global env + * opt-in. + * + * @param {object} input + * @param {import("@jsonbored/gittensory-engine").MinerKillSwitchScope} input.killSwitchScope + * @param {unknown} [input.repoLiveModeOptIn] `MinerGoalSpec.execution.liveModeOptIn` from the target repo + * @param {Record} [input.env] + * @returns {{ mode: import("@jsonbored/gittensory-engine").MinerActionMode, executes: boolean }} + */ +export function resolveMinerActionModeGate(input) { + const env = input.env ?? process.env; + const mode = resolveMinerActionMode({ + killSwitchScope: input.killSwitchScope, + repoLiveModeOptIn: input.repoLiveModeOptIn, + globalLiveModeOptIn: isGlobalMinerLiveModeOptIn(env), + }); + return { mode, executes: minerActionModeExecutes(mode) }; +} + +/** + * Record a dry-run shadow action (the WOULD-BE `LocalWriteActionSpec`) to the governor ledger, without ever + * invoking the actual command. + * + * @param {object} input + * @param {string} [input.repoFullName] + * @param {string} input.actionClass + * @param {Record} input.wouldBeAction + * @param {{ append?: typeof appendGovernorEvent }} [options] + */ +export function recordMinerDryRunShadow(input, options = {}) { + const append = options.append ?? appendGovernorEvent; + return append(buildMinerDryRunGovernorLedgerEvent(input)); +} 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 a441ae18a1..7ce87d07f3 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/deny-hook-synthesis.js && node --check lib/pretooluse-hook.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/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 && node --check lib/discover-cli.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/orb-export.js && node --check lib/portfolio-dashboard.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/rejection-state-machine.js && node --check lib/harness-submission-trigger.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/packages/gittensory-miner/schema/miner-goal-spec.schema.json b/packages/gittensory-miner/schema/miner-goal-spec.schema.json index 894d1294fc..e6d5a78d7e 100644 --- a/packages/gittensory-miner/schema/miner-goal-spec.schema.json +++ b/packages/gittensory-miner/schema/miner-goal-spec.schema.json @@ -93,6 +93,19 @@ "description": "Halts all miner WRITE actions for this repo without deregistering it from targeting/discovery. Default: false." } } + }, + "execution": { + "type": "object", + "additionalProperties": true, + "default": { "liveModeOptIn": null }, + "description": "Per-repo dry-run/live execution opt-in consulted by the Governor chokepoint (#2342). Default: { liveModeOptIn: null }.", + "properties": { + "liveModeOptIn": { + "type": ["string", "null"], + "default": null, + "description": "Must equal exactly \"live\" to opt this repo into live write execution; any other value stays dry-run. Also requires the operator's own global opt-in (GITTENSORY_MINER_LIVE_MODE=live) unless the operator's own config already grants it. Default: null." + } + } } } } diff --git a/test/unit/miner-goal-spec-doc.test.ts b/test/unit/miner-goal-spec-doc.test.ts index 5ca55022b9..4a34a9f5e1 100644 --- a/test/unit/miner-goal-spec-doc.test.ts +++ b/test/unit/miner-goal-spec-doc.test.ts @@ -20,6 +20,7 @@ const SPEC_FIELDS = [ "feasibilityGate", "selfPlagiarism", "killSwitch", + "execution", ] as const; describe("miner goal spec docs (#2300)", () => { @@ -59,6 +60,7 @@ describe("miner goal spec docs (#2300)", () => { feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }); expect(parsed.warnings).toEqual([]); }); diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 0ff54eb55b..52434513fd 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -57,6 +57,7 @@ describe("MinerGoalSpec parser (#2301)", () => { feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }, warnings: ['MinerGoalSpec field "blockedPaths" truncated an over-long entry.'], }); @@ -151,6 +152,7 @@ describe("MinerGoalSpec parser (#2301)", () => { feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }, warnings: expect.arrayContaining([ expect.stringMatching(/minerEnabled/i), @@ -241,6 +243,27 @@ describe("MinerGoalSpec parser (#2301)", () => { expect(arrayValue.warnings.join(" ")).toMatch(/killSwitch.*must be a mapping/i); }); + it("an execution.liveModeOptIn opt-in alone (all other fields default) marks the spec present", () => { + const parsed = parseMinerGoalSpec({ execution: { liveModeOptIn: "live" } }); + expect(parsed.present).toBe(true); + expect(parsed.spec.execution).toEqual({ liveModeOptIn: "live" }); + }); + + it("execution.liveModeOptIn requires the exact literal and rejects a non-mapping value", () => { + // A near-miss string is valid but not the magic literal -- silently stays dry-run, no warning. + const nearMiss = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: { liveModeOptIn: "LIVE" } }); + expect(nearMiss.spec.execution).toEqual({ liveModeOptIn: null }); + expect(nearMiss.warnings).toEqual([]); + + const wrongType = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: { liveModeOptIn: true } }); + expect(wrongType.spec.execution).toEqual({ liveModeOptIn: null }); + expect(wrongType.warnings.join(" ")).toMatch(/execution\.liveModeOptIn/i); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], execution: ["not", "a", "mapping"] }); + expect(arrayValue.spec.execution).toEqual({ liveModeOptIn: null }); + expect(arrayValue.warnings.join(" ")).toMatch(/execution.*must be a mapping/i); + }); + it("rejects claim counts below one after flooring", () => { const parsed = parseMinerGoalSpec({ wantedPaths: ["src/**"], @@ -271,6 +294,7 @@ describe("MinerGoalSpec parser (#2301)", () => { feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, killSwitch: { paused: false }, + execution: { liveModeOptIn: null }, }), ).toEqual({ present: false, diff --git a/test/unit/miner-governor-action-mode.test.ts b/test/unit/miner-governor-action-mode.test.ts new file mode 100644 index 0000000000..f8fd3d7407 --- /dev/null +++ b/test/unit/miner-governor-action-mode.test.ts @@ -0,0 +1,86 @@ +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 { resolveMinerActionModeGate, recordMinerDryRunShadow } from "../../packages/gittensory-miner/lib/governor-action-mode.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("resolveMinerActionModeGate (#2342)", () => { + it("defaults to dry_run with no config anywhere", () => { + expect(resolveMinerActionModeGate({ killSwitchScope: "none", env: {} })).toEqual({ + mode: "dry_run", + executes: false, + }); + }); + + it("the repo's exact opt-in flips to live", () => { + expect(resolveMinerActionModeGate({ killSwitchScope: "none", repoLiveModeOptIn: "live", env: {} })).toEqual({ + mode: "live", + executes: true, + }); + }); + + it("the operator's global env opt-in alone also flips to live", () => { + expect( + resolveMinerActionModeGate({ killSwitchScope: "none", env: { GITTENSORY_MINER_LIVE_MODE: "live" } }), + ).toEqual({ mode: "live", executes: true }); + }); + + it("a near-miss opt-in value stays dry_run (fail closed)", () => { + expect( + resolveMinerActionModeGate({ killSwitchScope: "none", repoLiveModeOptIn: "YES", env: { GITTENSORY_MINER_LIVE_MODE: "1" } }), + ).toEqual({ mode: "dry_run", executes: false }); + }); + + it("the kill-switch always wins over a live opt-in", () => { + expect( + resolveMinerActionModeGate({ killSwitchScope: "repo", repoLiveModeOptIn: "live", env: { GITTENSORY_MINER_LIVE_MODE: "live" } }), + ).toEqual({ mode: "paused", executes: false }); + }); + + it("defaults to reading process.env when no env override is given", () => { + const original = process.env.GITTENSORY_MINER_LIVE_MODE; + try { + process.env.GITTENSORY_MINER_LIVE_MODE = "live"; + expect(resolveMinerActionModeGate({ killSwitchScope: "none" })).toEqual({ mode: "live", executes: true }); + } finally { + if (original === undefined) delete process.env.GITTENSORY_MINER_LIVE_MODE; + else process.env.GITTENSORY_MINER_LIVE_MODE = original; + } + }); +}); + +describe("recordMinerDryRunShadow (#2342)", () => { + it("records the would-be action to the governor ledger without executing anything", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-action-mode-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + + const recorded = recordMinerDryRunShadow( + { repoFullName: "acme/widgets", actionClass: "open_pr", wouldBeAction: { action: "open_pr", title: "example" } }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(recorded.eventType).toBe("allowed"); + expect(recorded.decision).toBe("dry_run"); + expect(recorded.payload).toEqual({ wouldBeAction: { action: "open_pr", title: "example" } }); + + const rows = ledger.readGovernorEvents({ repoFullName: "acme/widgets" }); + expect(rows).toHaveLength(1); + expect(rows[0]?.decision).toBe("dry_run"); + }); +}); diff --git a/test/unit/miner-governor-chokepoint.test.ts b/test/unit/miner-governor-chokepoint.test.ts new file mode 100644 index 0000000000..8f310f680c --- /dev/null +++ b/test/unit/miner-governor-chokepoint.test.ts @@ -0,0 +1,367 @@ +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; +} + +/** A value whose every property access throws `thrown` -- used to exercise the non-Error `String(error)` + * fallback arm of chokepoint.ts's `error instanceof Error ? error.message : String(error)` catches. A plain + * `null` override (used below) always throws a genuine `TypeError` (a real `Error` instance), so this is + * needed to reach the fallback arm for a thrown non-Error value at each of the five calculator call sites. */ +function throwingProxy(thrown: unknown): unknown { + return new Proxy( + {}, + { + get(): never { + throw thrown; + }, + }, + ); +} + +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); + }); + + it("a caller-supplied rateLimitRandomFn is threaded through to the rate-limit calculator", () => { + const ledger = openLedger(); + let called = false; + 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, + rateLimitRandomFn: () => { + called = true; + return 0.25; + }, + }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.stage).toBe("rate_limit"); + expect(called).toBe(true); + }); + + it("a budget-cap denial records to the ledger as denied before non-convergence runs", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + baseInput({ capUsage: { budgetSpent: 100, turnsTaken: 0, elapsedMs: 0 }, capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 } }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("budget_cap"); + expect(result.recorded.eventType).toBe("denied"); + expect(result.decision.detail.convergence).toBeUndefined(); + }); + + it("a non-convergence denial records to the ledger before reputation/self-plagiarism run", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + baseInput({ convergenceInput: { attempts: 5, consecutiveFailures: 5, reenqueues: 0, reachedDone: false } }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("non_convergence"); + expect(result.recorded.eventType).toBe("denied"); + expect(result.decision.detail.reputation).toBeUndefined(); + }); + + it("a reputation-throttle denial records to the ledger as throttled before self-plagiarism runs", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ reputationHistory: { decided: 10, unfavorable: 8 } }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("reputation_throttle"); + expect(result.recorded.eventType).toBe("throttled"); + expect(result.decision.detail.selfPlagiarism).toBeUndefined(); + }); + + it("reputation throttle runs but does not throttle on insufficient history, reaching allow", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ reputationHistory: { decided: 1, unfavorable: 1 } }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(true); + expect(result.decision.detail.reputation?.reason).toBe("insufficient_history"); + }); + + it("reputation throttle is skipped entirely when reputationHistory is omitted", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput(), { append: (event) => ledger.appendGovernorEvent(event) }); + + expect(result.decision.allowed).toBe(true); + expect(result.decision.detail.reputation).toBeUndefined(); + }); + + it("reputation throttle and self-plagiarism are both skipped for a non-open_pr action, even with denying inputs", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + 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" }], + }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(true); + expect(result.decision.detail.reputation).toBeUndefined(); + expect(result.decision.detail.selfPlagiarism).toBeUndefined(); + }); + + it("a self-plagiarism denial records to the ledger as throttled", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + 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 }, + ], + }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("self_plagiarism"); + expect(result.recorded.eventType).toBe("throttled"); + }); + + it("self-plagiarism runs but allows a fingerprint distinct from recent submissions, reaching allow", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + baseInput({ + selfPlagiarismCandidate: { repoFullName: "acme/widgets", fingerprint: "a wholly distinct fingerprint", submittedAt: "2026-07-11T12:00:00Z" }, + }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(true); + expect(result.decision.detail.selfPlagiarism?.reason).toBe("distinct_from_recent_own_submissions"); + }); + + it("self-plagiarism denies via a whitespace-only fingerprint with no computed similarity", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate( + baseInput({ + selfPlagiarismCandidate: { repoFullName: "acme/widgets", fingerprint: " ", submittedAt: "2026-07-11T12:00:00Z" }, + }), + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("self_plagiarism"); + expect(result.decision.reason).toBe("missing_candidate_fingerprint"); + }); + + it("self-plagiarism is skipped entirely when selfPlagiarismCandidate is omitted", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput(), { append: (event) => ledger.appendGovernorEvent(event) }); + + expect(result.decision.allowed).toBe(true); + expect(result.decision.detail.selfPlagiarism).toBeUndefined(); + }); + + it("a rate-limit calculator error denies closed with stage internal_error, never falling through to allow", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ rateLimitBuckets: null }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toContain("rate_limit_calculator_error"); + expect(result.recorded.eventType).toBe("denied"); + }); + + it("a rate-limit calculator throwing a non-Error value still formats a reason via String(error)", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ rateLimitBuckets: throwingProxy("boom: not an Error") }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toBe("rate_limit_calculator_error: boom: not an Error"); + }); + + it("a budget-cap calculator error denies closed with stage internal_error", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ capUsage: null }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toContain("budget_cap_calculator_error"); + }); + + it("a budget-cap calculator throwing a non-Error value still formats a reason via String(error)", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ capUsage: throwingProxy("boom: not an Error") }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toBe("budget_cap_calculator_error: boom: not an Error"); + }); + + it("a non-convergence calculator error denies closed with stage internal_error", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ convergenceInput: null }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toContain("non_convergence_calculator_error"); + }); + + it("a non-convergence calculator throwing a non-Error value still formats a reason via String(error)", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ convergenceInput: throwingProxy("boom: not an Error") }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toBe("non_convergence_calculator_error: boom: not an Error"); + }); + + it("a reputation-throttle calculator error denies rather than silently skipping the stage", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ reputationHistory: null }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toContain("reputation_throttle_calculator_error"); + }); + + it("a reputation-throttle calculator throwing a non-Error value still formats a reason via String(error)", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ reputationHistory: throwingProxy("boom: not an Error") }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toBe("reputation_throttle_calculator_error: boom: not an Error"); + }); + + it("a self-plagiarism calculator error denies rather than silently skipping the stage", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ selfPlagiarismCandidate: null }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toContain("self_plagiarism_calculator_error"); + }); + + it("a self-plagiarism calculator throwing a non-Error value still formats a reason via String(error)", () => { + const ledger = openLedger(); + const result = evaluateGovernorChokepointGate(baseInput({ selfPlagiarismCandidate: throwingProxy("boom: not an Error") }), { + append: (event) => ledger.appendGovernorEvent(event), + }); + + expect(result.decision.stage).toBe("internal_error"); + expect(result.decision.reason).toBe("self_plagiarism_calculator_error: boom: not an Error"); + }); +});