Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gittensory-miner.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 75 additions & 0 deletions packages/gittensory-engine/src/governor/kill-switch.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>): 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 },
};
}
2 changes: 2 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -338,6 +339,7 @@ export {
type FeasibilityGatePolicy,
type MinerGoalSpec,
type MinerIssueDiscoveryPolicy,
type MinerKillSwitchPolicy,
type ParsedMinerGoalSpec,
} from "./miner-goal-spec.js";
export {
Expand Down
38 changes: 37 additions & 1 deletion packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -112,6 +127,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = 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;
Expand All @@ -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 },
};
}

Expand Down Expand Up @@ -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<string, unknown>;
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)) {
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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.");
Expand Down
107 changes: 107 additions & 0 deletions packages/gittensory-engine/test/kill-switch.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
16 changes: 16 additions & 0 deletions packages/gittensory-engine/test/miner-goal-spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"],
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-engine/test/miner-goal-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
});

Expand All @@ -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", () => {
Expand All @@ -41,6 +43,7 @@ test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () =
"blockedPaths",
"feasibilityGate",
"issueDiscoveryPolicy",
"killSwitch",
"maxConcurrentClaims",
"minerEnabled",
"preferredLabels",
Expand Down
6 changes: 6 additions & 0 deletions packages/gittensory-miner/docs/miner-goal-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions packages/gittensory-miner/lib/governor-kill-switch.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
};

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;
Loading