diff --git a/.gittensory-miner.yml.example b/.gittensory-miner.yml.example index e5e666f0f4..fa5db4f202 100644 --- a/.gittensory-miner.yml.example +++ b/.gittensory-miner.yml.example @@ -65,3 +65,11 @@ feasibilityGate: # Float in [0, 1]. Default: 0.85. selfPlagiarism: similarityThreshold: 0.85 + +# Per-repo kill-switch (#2341) consulted by the Governor chokepoint before every write action. Distinct from +# minerEnabled: pausing halts an already-in-flight queue without deregistering the repo from discovery, and +# un-pausing resumes exactly where the queue left off. A separate 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). +killSwitch: + paused: false diff --git a/packages/gittensory-engine/src/governor/kill-switch.ts b/packages/gittensory-engine/src/governor/kill-switch.ts new file mode 100644 index 0000000000..3ffa22cc97 --- /dev/null +++ b/packages/gittensory-engine/src/governor/kill-switch.ts @@ -0,0 +1,75 @@ +// Governor kill-switch (#2341): the emergency-halt primitive every write-adjacent governor decision consults +// FIRST, before any other calculator. Two independent triggers compose into one scope: a GLOBAL env-level +// switch that halts every repo at once, and a PER-REPO switch (from `.gittensory-miner.yml`'s MinerGoalSpec +// `killSwitch.paused` field) that halts only its own repo's queue while leaving the rest of the fleet running. +// Mirrors `src/settings/agent-execution.ts`'s `isGlobalAgentPause` truthy-string idiom for the review-stack's +// own kill-switch (#776) — a parallel mechanism for the miner's own local runtime, not the same one. +// +// DETECTOR ONLY — no IO, no persistence. Composing this with the other pure calculators into one fail-closed +// allow/deny verdict (and recording every CHECK, not just a transition) is the Governor chokepoint's job +// (#2340), which consults this module first in its "safest wins" precedence. + +import type { GovernorLedgerEvent } from "../governor-ledger.js"; + +/** Truthy-string idiom shared with `isGlobalAgentPause` (`src/settings/agent-execution.ts`) — same accepted + * literal set, so an operator only needs to remember one convention across both kill-switches. */ +const TRUTHY_ENV_VALUE = /^(1|true|yes|on)$/i; + +/** Env var an operator sets to halt ALL miner write activity, across every repo, immediately. */ +export const MINER_KILL_SWITCH_ENV_VAR = "GITTENSORY_MINER_KILL_SWITCH"; + +/** Which trigger (if any) is currently halting miner write activity for a given repo. */ +export type MinerKillSwitchScope = "global" | "repo" | "none"; + +/** + * True when the operator's global env-level kill-switch is set. Mirrors `isGlobalAgentPause`'s idiom exactly + * (case-insensitive `1`/`true`/`yes`/`on`) — absence or any other value reads as not tripped. This function + * does not itself fail closed; the caller composing it into a decision (the Governor chokepoint) is + * responsible for that. + */ +export function isGlobalMinerKillSwitch(env: Record): boolean { + return TRUTHY_ENV_VALUE.test(env[MINER_KILL_SWITCH_ENV_VAR] ?? ""); +} + +/** + * Resolve which kill-switch scope (if any) is active for a repo. Pure and stateless: identical inputs always + * yield the identical scope, so toggling either input off on the next call immediately reflects "resumed" with + * no residual state here to corrupt — any queue/attempt state a caller holds is untouched by this resolution. + * Precedence: a global halt always reports as `"global"`, regardless of the per-repo flag (never masked); a + * per-repo pause alone is sufficient to halt just that repo. + */ +export function resolveMinerKillSwitch(input: { global: boolean; repoPaused?: boolean | null | undefined }): MinerKillSwitchScope { + if (input.global) return "global"; + if (input.repoPaused === true) return "repo"; + return "none"; +} + +/** True for any active scope (`"global"` or `"repo"`) — false only for `"none"`. */ +export function isMinerKillSwitchActive(scope: MinerKillSwitchScope): boolean { + return scope !== "none"; +} + +/** + * Governor-ledger row for a kill-switch STATE TRANSITION (#2341's "state changes are themselves recorded" + * deliverable) — call only when the scope actually changed since the previous check, not on every check (every + * check's allow/deny for a real write action is the Governor chokepoint's job, #2340, not this primitive's). + * Returns `null` when there is no transition, so a caller can unconditionally call this each check and only + * append when it returns non-null. + */ +export function buildMinerKillSwitchTransitionGovernorLedgerEvent(input: { + repoFullName?: string | null | undefined; + actionClass: string; + previousScope: MinerKillSwitchScope; + scope: MinerKillSwitchScope; +}): GovernorLedgerEvent | null { + if (input.previousScope === input.scope) return null; + const tripped = isMinerKillSwitchActive(input.scope); + return { + eventType: "kill_switch", + repoFullName: input.repoFullName ?? null, + actionClass: input.actionClass, + decision: tripped ? "tripped" : "resumed", + reason: tripped ? `${input.scope}_kill_switch_engaged` : `${input.previousScope}_kill_switch_cleared`, + payload: { previousScope: input.previousScope, scope: input.scope }, + }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 1fe50d2f52..9f275bc0e9 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -149,6 +149,7 @@ export * from "./governor/self-plagiarism.js"; 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 { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, @@ -338,6 +339,7 @@ export { type FeasibilityGatePolicy, type MinerGoalSpec, type MinerIssueDiscoveryPolicy, + type MinerKillSwitchPolicy, type ParsedMinerGoalSpec, } from "./miner-goal-spec.js"; export { diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index 57d26ea03e..11fbcabc62 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -34,6 +34,16 @@ export type SelfPlagiarismPolicy = { similarityThreshold: number; }; +/** Per-repo kill-switch tuning consulted by the Governor chokepoint's kill-switch primitive (#2341). */ +export type MinerKillSwitchPolicy = { + /** + * Per-repo runtime halt: stops all miner WRITE actions for this repo without deregistering it from + * targeting/discovery. Distinct from `minerEnabled` (a discovery-time opt-out) — pausing preserves in-flight + * queue state so un-pausing resumes exactly where the queue left off. Default: false (not paused). + */ + paused: boolean; +}; + /** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */ export type MinerGoalSpec = { /** @@ -82,6 +92,11 @@ export type MinerGoalSpec = { * Self-plagiarism throttle consulted before open_pr (#2345). Default: { similarityThreshold: 0.85 }. */ selfPlagiarism: SelfPlagiarismPolicy; + /** + * Per-repo kill-switch consulted by the Governor chokepoint before every write action (#2341). + * Default: { paused: false }. + */ + killSwitch: MinerKillSwitchPolicy; }; /** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the @@ -112,6 +127,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ issueDiscoveryPolicy: "neutral", feasibilityGate: Object.freeze({ enabled: true, suppressedReasons: Object.freeze([]) }), selfPlagiarism: Object.freeze({ similarityThreshold: DEFAULT_SELF_PLAGIARISM_SIMILARITY_THRESHOLD }), + killSwitch: Object.freeze({ paused: false }), }); const MAX_MINER_GOAL_SPEC_BYTES = 32_768; @@ -130,6 +146,7 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { suppressedReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressedReasons], }, selfPlagiarism: { ...DEFAULT_MINER_GOAL_SPEC.selfPlagiarism }, + killSwitch: { ...DEFAULT_MINER_GOAL_SPEC.killSwitch }, }; } @@ -232,6 +249,23 @@ function normalizeSelfPlagiarismPolicy( return resolved; } +function normalizeKillSwitchPolicy( + value: unknown, + field: string, + fallback: MinerKillSwitchPolicy, + warnings: string[], +): MinerKillSwitchPolicy { + 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; + return { + paused: normalizeBoolean(record.paused, `${field}.paused`, fallback.paused, warnings), + }; +} + 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)) { @@ -267,7 +301,8 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy || spec.feasibilityGate.enabled !== DEFAULT_MINER_GOAL_SPEC.feasibilityGate.enabled || spec.feasibilityGate.suppressedReasons.length > 0 || - spec.selfPlagiarism.similarityThreshold !== DEFAULT_MINER_GOAL_SPEC.selfPlagiarism.similarityThreshold + spec.selfPlagiarism.similarityThreshold !== DEFAULT_MINER_GOAL_SPEC.selfPlagiarism.similarityThreshold || + spec.killSwitch.paused !== DEFAULT_MINER_GOAL_SPEC.killSwitch.paused ); } @@ -320,6 +355,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { DEFAULT_MINER_GOAL_SPEC.selfPlagiarism, warnings, ), + killSwitch: normalizeKillSwitchPolicy(record.killSwitch, "killSwitch", DEFAULT_MINER_GOAL_SPEC.killSwitch, 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/kill-switch.test.ts b/packages/gittensory-engine/test/kill-switch.test.ts new file mode 100644 index 0000000000..b34abe7a6b --- /dev/null +++ b/packages/gittensory-engine/test/kill-switch.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + MINER_KILL_SWITCH_ENV_VAR, + buildMinerKillSwitchTransitionGovernorLedgerEvent, + isGlobalMinerKillSwitch, + isMinerKillSwitchActive, + resolveMinerKillSwitch, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports the kill-switch primitive (#2341)", () => { + assert.equal(typeof isGlobalMinerKillSwitch, "function"); + assert.equal(typeof resolveMinerKillSwitch, "function"); + assert.equal(typeof isMinerKillSwitchActive, "function"); + assert.equal(typeof buildMinerKillSwitchTransitionGovernorLedgerEvent, "function"); + assert.equal(MINER_KILL_SWITCH_ENV_VAR, "GITTENSORY_MINER_KILL_SWITCH"); +}); + +test("isGlobalMinerKillSwitch: accepts the same truthy-string idiom as isGlobalAgentPause", () => { + for (const value of ["1", "true", "TRUE", "yes", "on", "On"]) { + assert.equal(isGlobalMinerKillSwitch({ GITTENSORY_MINER_KILL_SWITCH: value }), true, `expected ${value} to be truthy`); + } + for (const value of [undefined, "", "0", "false", "no", "off", "banana"]) { + assert.equal(isGlobalMinerKillSwitch({ GITTENSORY_MINER_KILL_SWITCH: value }), false, `expected ${String(value)} to be falsy`); + } +}); + +test("resolveMinerKillSwitch: global denies regardless of per-repo state", () => { + assert.equal(resolveMinerKillSwitch({ global: true, repoPaused: false }), "global"); + assert.equal(resolveMinerKillSwitch({ global: true, repoPaused: true }), "global"); + assert.equal(resolveMinerKillSwitch({ global: true, repoPaused: undefined }), "global"); +}); + +test("resolveMinerKillSwitch: per-repo pause denies only when global is not tripped", () => { + assert.equal(resolveMinerKillSwitch({ global: false, repoPaused: true }), "repo"); + assert.equal(resolveMinerKillSwitch({ global: false, repoPaused: false }), "none"); + assert.equal(resolveMinerKillSwitch({ global: false, repoPaused: undefined }), "none"); +}); + +test("resolveMinerKillSwitch: toggling off resumes immediately with no residual state (pure/stateless)", () => { + const pausedRepoA = resolveMinerKillSwitch({ global: false, repoPaused: true }); + const resumedRepoA = resolveMinerKillSwitch({ global: false, repoPaused: false }); + const stillPausedRepoB = resolveMinerKillSwitch({ global: false, repoPaused: true }); + assert.equal(pausedRepoA, "repo"); + assert.equal(resumedRepoA, "none"); + // Resuming repo A must not leak into a separate repo's independently-tracked pause state. + assert.equal(stillPausedRepoB, "repo"); +}); + +test("isMinerKillSwitchActive: true for any active scope, false only for none", () => { + assert.equal(isMinerKillSwitchActive("global"), true); + assert.equal(isMinerKillSwitchActive("repo"), true); + assert.equal(isMinerKillSwitchActive("none"), false); +}); + +test("buildMinerKillSwitchTransitionGovernorLedgerEvent: no-op when the scope has not changed", () => { + assert.equal( + buildMinerKillSwitchTransitionGovernorLedgerEvent({ + actionClass: "open_pr", + previousScope: "none", + scope: "none", + }), + null, + ); + assert.equal( + buildMinerKillSwitchTransitionGovernorLedgerEvent({ + actionClass: "open_pr", + previousScope: "global", + scope: "global", + }), + null, + ); +}); + +test("buildMinerKillSwitchTransitionGovernorLedgerEvent: engaging the switch records a tripped kill_switch event", () => { + const event = buildMinerKillSwitchTransitionGovernorLedgerEvent({ + repoFullName: "acme/widgets", + actionClass: "open_pr", + previousScope: "none", + scope: "repo", + }); + assert.deepEqual(event, { + eventType: "kill_switch", + repoFullName: "acme/widgets", + actionClass: "open_pr", + decision: "tripped", + reason: "repo_kill_switch_engaged", + payload: { previousScope: "none", scope: "repo" }, + }); +}); + +test("buildMinerKillSwitchTransitionGovernorLedgerEvent: clearing the switch records a resumed kill_switch event", () => { + const event = buildMinerKillSwitchTransitionGovernorLedgerEvent({ + actionClass: "open_pr", + previousScope: "global", + scope: "none", + }); + assert.deepEqual(event, { + eventType: "kill_switch", + repoFullName: null, + actionClass: "open_pr", + decision: "resumed", + reason: "global_kill_switch_cleared", + payload: { previousScope: "global", scope: "none" }, + }); +}); 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 68873ac2fe..198a1c72a3 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -48,10 +48,25 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- issueDiscoveryPolicy: "encouraged", feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }); assert.deepEqual(parsed.warnings, []); }); +test("parseMinerGoalSpec: killSwitch sub-field normalizes independently and rejects a non-mapping value", () => { + const valid = parseMinerGoalSpec({ wantedPaths: ["src/**"], killSwitch: { paused: true } }); + assert.deepEqual(valid.spec.killSwitch, { paused: true }); + assert.deepEqual(valid.warnings, []); + + const malformed = parseMinerGoalSpec({ wantedPaths: ["src/**"], killSwitch: { paused: "nope" } }); + assert.deepEqual(malformed.spec.killSwitch, { paused: false }); + assert.match(malformed.warnings.join(" "), /killSwitch\.paused/i); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], killSwitch: ["not", "a", "mapping"] }); + assert.deepEqual(arrayValue.spec.killSwitch, { paused: false }); + assert.match(arrayValue.warnings.join(" "), /killSwitch.*must be a mapping/i); +}); + test("parseMinerGoalSpec: feasibilityGate sub-fields normalize independently and reject a non-mapping value", () => { const valid = parseMinerGoalSpec({ wantedPaths: ["src/**"], @@ -157,6 +172,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted issueDiscoveryPolicy: "neutral", feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }); 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 6f305229c3..70793430e3 100644 --- a/packages/gittensory-engine/test/miner-goal-spec.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -21,6 +21,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { issueDiscoveryPolicy: "neutral", feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: Object.freeze({ similarityThreshold: 0.85 }), + killSwitch: Object.freeze({ paused: false }), }); }); @@ -33,6 +34,7 @@ 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)); 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)); }); test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { @@ -41,6 +43,7 @@ test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () = "blockedPaths", "feasibilityGate", "issueDiscoveryPolicy", + "killSwitch", "maxConcurrentClaims", "minerEnabled", "preferredLabels", diff --git a/packages/gittensory-miner/docs/miner-goal-spec.md b/packages/gittensory-miner/docs/miner-goal-spec.md index 6599ba6dfd..c84bbe44c0 100644 --- a/packages/gittensory-miner/docs/miner-goal-spec.md +++ b/packages/gittensory-miner/docs/miner-goal-spec.md @@ -62,3 +62,9 @@ Per-repo tuning for the feasibility gate (`buildFeasibilityVerdict`) a miner con Per-repo tuning for the Governor self-plagiarism throttle consulted before `open_pr` (#2345). Compares a prospective PR's diff fingerprint against the miner's own recent submission history. - `similarityThreshold` (number in `[0, 1]`, default: `0.85`) — Jaccard similarity at/above which two fingerprints read as near-duplicates across repos. + +### `killSwitch` (object, default: `{ paused: false }`) + +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. diff --git a/packages/gittensory-miner/lib/governor-kill-switch.d.ts b/packages/gittensory-miner/lib/governor-kill-switch.d.ts new file mode 100644 index 0000000000..c5d60d6259 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-kill-switch.d.ts @@ -0,0 +1,26 @@ +import type { MinerKillSwitchScope } from "@jsonbored/gittensory-engine"; +import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js"; + +export type CheckMinerKillSwitchInput = { + repoPaused?: boolean; + env?: Record; +}; + +export type CheckMinerKillSwitchResult = { + scope: MinerKillSwitchScope; + active: boolean; +}; + +export function checkMinerKillSwitch(input?: CheckMinerKillSwitchInput): CheckMinerKillSwitchResult; + +export type RecordMinerKillSwitchTransitionInput = { + repoFullName?: string; + actionClass: string; + previousScope: MinerKillSwitchScope; + scope: MinerKillSwitchScope; +}; + +export function recordMinerKillSwitchTransition( + input: RecordMinerKillSwitchTransitionInput, + options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry }, +): GovernorLedgerEntry | null; diff --git a/packages/gittensory-miner/lib/governor-kill-switch.js b/packages/gittensory-miner/lib/governor-kill-switch.js new file mode 100644 index 0000000000..57a733915f --- /dev/null +++ b/packages/gittensory-miner/lib/governor-kill-switch.js @@ -0,0 +1,47 @@ +// Governor kill-switch gate (#2341). Resolves whether miner write activity is currently halted (globally, via +// env, or for one repo, via its .gittensory-miner.yml MinerGoalSpec) and records STATE TRANSITIONS to the +// append-only governor ledger. Every-check allow/deny recording for a real write action is the fail-closed +// Governor chokepoint's job (#2340), which consults this module first in its "safest wins" precedence. + +import { + buildMinerKillSwitchTransitionGovernorLedgerEvent, + isGlobalMinerKillSwitch, + isMinerKillSwitchActive, + resolveMinerKillSwitch, +} from "@jsonbored/gittensory-engine"; +import { appendGovernorEvent } from "./governor-ledger.js"; + +/** + * Resolve the current kill-switch scope for a repo from process env plus a per-repo paused flag (typically + * `MinerGoalSpec.killSwitch.paused` from the repo's parsed `.gittensory-miner.yml`). + * + * @param {object} [input] + * @param {boolean} [input.repoPaused] + * @param {Record} [input.env] + * @returns {{ scope: import("@jsonbored/gittensory-engine").MinerKillSwitchScope, active: boolean }} + */ +export function checkMinerKillSwitch(input = {}) { + const env = input.env ?? process.env; + const global = isGlobalMinerKillSwitch(env); + const scope = resolveMinerKillSwitch({ global, repoPaused: input.repoPaused }); + return { scope, active: isMinerKillSwitchActive(scope) }; +} + +/** + * Record a kill-switch state transition to the governor ledger. No-op (returns null, appends nothing) when the + * scope has not actually changed since the previous check — callers own tracking the previous scope (in-memory + * or persisted); this module holds no state of its own. + * + * @param {object} input + * @param {string} [input.repoFullName] + * @param {string} input.actionClass + * @param {import("@jsonbored/gittensory-engine").MinerKillSwitchScope} input.previousScope + * @param {import("@jsonbored/gittensory-engine").MinerKillSwitchScope} input.scope + * @param {{ append?: typeof appendGovernorEvent }} [options] + */ +export function recordMinerKillSwitchTransition(input, options = {}) { + const event = buildMinerKillSwitchTransitionGovernorLedgerEvent(input); + if (!event) return null; + const append = options.append ?? appendGovernorEvent; + return append(event); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 6c38514917..63f6472b00 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/governor-kill-switch.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/packages/gittensory-miner/schema/miner-goal-spec.schema.json b/packages/gittensory-miner/schema/miner-goal-spec.schema.json index 5f8a75f915..894d1294fc 100644 --- a/packages/gittensory-miner/schema/miner-goal-spec.schema.json +++ b/packages/gittensory-miner/schema/miner-goal-spec.schema.json @@ -80,6 +80,19 @@ "description": "Jaccard similarity threshold for near-duplicate diff fingerprints. Default: 0.85." } } + }, + "killSwitch": { + "type": "object", + "additionalProperties": true, + "default": { "paused": false }, + "description": "Per-repo kill-switch consulted by the Governor chokepoint before every write action (#2341). Default: { paused: false }.", + "properties": { + "paused": { + "type": "boolean", + "default": false, + "description": "Halts all miner WRITE actions for this repo without deregistering it from targeting/discovery. Default: false." + } + } } } } diff --git a/test/unit/miner-goal-spec-doc.test.ts b/test/unit/miner-goal-spec-doc.test.ts index 01bc846bd9..5ca55022b9 100644 --- a/test/unit/miner-goal-spec-doc.test.ts +++ b/test/unit/miner-goal-spec-doc.test.ts @@ -19,6 +19,7 @@ const SPEC_FIELDS = [ "issueDiscoveryPolicy", "feasibilityGate", "selfPlagiarism", + "killSwitch", ] as const; describe("miner goal spec docs (#2300)", () => { @@ -57,6 +58,7 @@ describe("miner goal spec docs (#2300)", () => { issueDiscoveryPolicy: "neutral", feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }); 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 6d1d35d915..0ff54eb55b 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -56,6 +56,7 @@ describe("MinerGoalSpec parser (#2301)", () => { issueDiscoveryPolicy: "encouraged", feasibilityGate: { enabled: false, suppressedReasons: ["duplicate_cluster_high"] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }, warnings: ['MinerGoalSpec field "blockedPaths" truncated an over-long entry.'], }); @@ -149,6 +150,7 @@ describe("MinerGoalSpec parser (#2301)", () => { issueDiscoveryPolicy: "neutral", feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }, warnings: expect.arrayContaining([ expect.stringMatching(/minerEnabled/i), @@ -220,6 +222,25 @@ describe("MinerGoalSpec parser (#2301)", () => { expect(arrayValue.warnings.join(" ")).toMatch(/selfPlagiarism.*must be a mapping/i); }); + it("a killSwitch policy alone (all other fields default) marks the spec present", () => { + const parsed = parseMinerGoalSpec({ killSwitch: { paused: true } }); + expect(parsed.present).toBe(true); + expect(parsed.spec.killSwitch).toEqual({ paused: true }); + }); + + it("normalizes nested killSwitch sub-fields and rejects a non-mapping value", () => { + const malformed = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + killSwitch: { paused: "nope" }, + }); + expect(malformed.spec.killSwitch).toEqual({ paused: false }); + expect(malformed.warnings.join(" ")).toMatch(/killSwitch\.paused/i); + + const arrayValue = parseMinerGoalSpec({ wantedPaths: ["src/**"], killSwitch: ["not", "a", "mapping"] }); + expect(arrayValue.spec.killSwitch).toEqual({ paused: false }); + expect(arrayValue.warnings.join(" ")).toMatch(/killSwitch.*must be a mapping/i); + }); + it("rejects claim counts below one after flooring", () => { const parsed = parseMinerGoalSpec({ wantedPaths: ["src/**"], @@ -249,6 +270,7 @@ describe("MinerGoalSpec parser (#2301)", () => { issueDiscoveryPolicy: "neutral", feasibilityGate: { enabled: true, suppressedReasons: [] }, selfPlagiarism: { similarityThreshold: 0.85 }, + killSwitch: { paused: false }, }), ).toEqual({ present: false, diff --git a/test/unit/miner-governor-kill-switch.test.ts b/test/unit/miner-governor-kill-switch.test.ts new file mode 100644 index 0000000000..068f7d2514 --- /dev/null +++ b/test/unit/miner-governor-kill-switch.test.ts @@ -0,0 +1,91 @@ +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 { checkMinerKillSwitch, recordMinerKillSwitchTransition } from "../../packages/gittensory-miner/lib/governor-kill-switch.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("checkMinerKillSwitch (#2341)", () => { + it("global env switch halts regardless of per-repo state", () => { + expect(checkMinerKillSwitch({ repoPaused: false, env: { GITTENSORY_MINER_KILL_SWITCH: "true" } })).toEqual({ + scope: "global", + active: true, + }); + expect(checkMinerKillSwitch({ repoPaused: true, env: { GITTENSORY_MINER_KILL_SWITCH: "true" } })).toEqual({ + scope: "global", + active: true, + }); + }); + + it("per-repo pause halts only when the global switch is not tripped", () => { + expect(checkMinerKillSwitch({ repoPaused: true, env: {} })).toEqual({ scope: "repo", active: true }); + expect(checkMinerKillSwitch({ repoPaused: false, env: {} })).toEqual({ scope: "none", active: false }); + }); + + it("defaults to reading process.env when no env override is given", () => { + const original = process.env.GITTENSORY_MINER_KILL_SWITCH; + try { + process.env.GITTENSORY_MINER_KILL_SWITCH = "1"; + expect(checkMinerKillSwitch({ repoPaused: false })).toEqual({ scope: "global", active: true }); + } finally { + if (original === undefined) delete process.env.GITTENSORY_MINER_KILL_SWITCH; + else process.env.GITTENSORY_MINER_KILL_SWITCH = original; + } + }); +}); + +describe("recordMinerKillSwitchTransition (#2341)", () => { + it("records a tripped transition to the governor ledger and resuming records a second row", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-kill-switch-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + + const tripped = recordMinerKillSwitchTransition( + { repoFullName: "acme/widgets", actionClass: "open_pr", previousScope: "none", scope: "repo" }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + expect(tripped?.eventType).toBe("kill_switch"); + expect(tripped?.decision).toBe("tripped"); + + const resumed = recordMinerKillSwitchTransition( + { repoFullName: "acme/widgets", actionClass: "open_pr", previousScope: "repo", scope: "none" }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + expect(resumed?.decision).toBe("resumed"); + + const rows = ledger.readGovernorEvents({ repoFullName: "acme/widgets" }); + expect(rows).toHaveLength(2); + expect(rows[0]?.id).toBeLessThan(rows[1]?.id ?? 0); + }); + + it("is a no-op and appends nothing when the scope has not changed", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-kill-switch-noop-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + const append = vi.fn((event: Parameters[0]) => ledger.appendGovernorEvent(event)); + + const result = recordMinerKillSwitchTransition( + { actionClass: "open_pr", previousScope: "none", scope: "none" }, + { append }, + ); + + expect(result).toBeNull(); + expect(append).not.toHaveBeenCalled(); + expect(ledger.readGovernorEvents({})).toHaveLength(0); + }); +});