diff --git a/orb-manifest.json b/orb-manifest.json index f398f109cd..4ca270038f 100644 --- a/orb-manifest.json +++ b/orb-manifest.json @@ -1,4 +1,4 @@ { - "version": "0.4.0", + "version": "0.4.1", "description": "Source of truth for the self-hostable gittensory-orb container image's target release version (ghcr.io/jsonbored/gittensory-selfhost). Bumped by a maintainer when a feat/fix/breaking change since the last stable orb-v tag warrants moving to a new target version -- scripts/orb-release-core.mjs and .github/workflows/orb-beta-release.yml read this file to decide what version the next automated beta snapshot targets. Promoting a beta to a stable orb-vX.Y.Z release is still a manual `git tag` -- this manifest only drives the automated beta channel." } diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 62d88e5356..a10c01a74f 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -253,6 +253,12 @@ export { type LoopReentryDecision, type LoopReentryOutcome, } from "./miner/loop-reentry-policy.js"; +export { + DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, + evaluateHarnessSubmissionTrigger, + type HarnessSubmissionTriggerCandidate, + type HarnessSubmissionTriggerDecision, +} from "./miner/harness-submission-trigger.js"; export { codingAgentModeExecutes, isGlobalMinerCodingAgentPause, diff --git a/packages/gittensory-engine/src/miner/harness-submission-trigger.ts b/packages/gittensory-engine/src/miner/harness-submission-trigger.ts new file mode 100644 index 0000000000..e26c53a4ec --- /dev/null +++ b/packages/gittensory-engine/src/miner/harness-submission-trigger.ts @@ -0,0 +1,81 @@ +// Harness submission-gate wiring (#2337): connects the gated-submission decision function (`shouldSubmit`, +// submission-gate.ts, #2336) to the ACTUAL driving loop's own handoff signal -- iterate-loop.ts's (#2333) +// `HandoffPacket`, the exact object produced the moment a real run's self-review reaches a clean predicted-gate +// pass. This is the live actuation wiring itself: the trigger surface the safety-tier system reserves for +// maintainer review, since a bug here means an autonomous write happens when it should not have. +// +// WHAT THIS DOES NOT DO: build or invoke the actual `open_pr` local-write spec (`buildOpenPrSpec`, +// `src/mcp/local-write-tools.ts`) -- that lives in the private root `src/` tree, unreachable from this +// portable package for the same cross-package-boundary reason self-review-adapter.ts's slop injection exists +// (#2334's own module doc comment). This function's OUTPUT (`allow: true`) is the gate a real call site +// (root-side server/CLI integration, wired in a later issue) consults before it builds that spec itself -- +// mirrors #2336's own "gated exclusively through this function" scoping. +// +// THE SESSION-LEVEL CIRCUIT BREAKER: distinct from `shouldSubmit`'s own per-candidate signal checks +// (predicted-gate pass, slop-under-threshold), this issue's own deliverable calls for "N consecutive +// submission-gate allow:false decisions in one session pauses the run entirely pending human review, never +// silently loops trying to force a pass." Checked FIRST, before ever consulting `shouldSubmit` -- once tripped, +// no candidate can un-trip it (that requires a human clearing the session's own consecutive-block tally), +// unlike a per-candidate block which a later, different candidate can clear on its own merits. + +import type { MinerKillSwitchScope } from "../governor/kill-switch.js"; +import type { HandoffPacket } from "./iterate-policy.js"; +import type { SelfReviewSlopBand } from "./self-review-adapter.js"; +import { shouldSubmit, type SubmissionGateMode } from "./submission-gate.js"; + +export const DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS = 3; + +export type HarnessSubmissionTriggerCandidate = { + /** Forwarded to `shouldSubmit`'s own kill-switch check (#2339) -- this function does not ALSO short-circuit + * on it separately (that would be a second, duplicated check, exactly what #2339's "single shared helper, + * not duplicated per call site" deliverable warns against); `shouldSubmit` is always still called (the + * circuit breaker above is the only thing that skips it), and its own kill-switch guard covers this. */ + killSwitchScope: MinerKillSwitchScope; + handoffPacket: HandoffPacket; + slopThreshold: SelfReviewSlopBand; + mode: SubmissionGateMode; + /** Caller-computed count of CONSECUTIVE `allow: false` submission-gate decisions so far this session, + * ending with (and NOT including) this candidate's own about-to-be-computed decision. The caller owns this + * tally (mirrors #2338's caller-supplied `consecutiveDisengagements`); a `true` decision anywhere resets it + * to 0 for the caller's NEXT candidate. */ + consecutiveGateBlocks: number; + maxConsecutiveGateBlocks?: number | undefined; +}; + +export type HarnessSubmissionTriggerDecision = { + allow: boolean; + reasons: string[]; + /** True only when the SESSION-LEVEL circuit breaker (not a normal per-candidate block) is what stopped this + * decision -- the caller's own driving loop should treat this as "pause the run entirely pending human + * review," distinct from an ordinary `allow: false` a later, different candidate might still clear. */ + circuitBreakerTripped: boolean; +}; + +/** + * THE final gate before a real call site may build the `open_pr` local-write spec from a passing + * `HandoffPacket`. Pure; identical inputs always yield the identical decision. Checks the session-level + * circuit breaker FIRST (never consults `shouldSubmit` once tripped), then re-checks `shouldSubmit`'s own + * predicted-gate-pass + slop-under-threshold signals against the handoff's own verdict -- defense in depth, + * not a blind trust of the fact that a handoff happened at all. + */ +export function evaluateHarnessSubmissionTrigger(candidate: HarnessSubmissionTriggerCandidate): HarnessSubmissionTriggerDecision { + const maxConsecutiveGateBlocks = candidate.maxConsecutiveGateBlocks ?? DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS; + + if (candidate.consecutiveGateBlocks >= maxConsecutiveGateBlocks) { + return { + allow: false, + circuitBreakerTripped: true, + reasons: [`circuit_breaker_tripped_after_consecutive_blocks:${candidate.consecutiveGateBlocks}>=${maxConsecutiveGateBlocks}`], + }; + } + + const gateDecision = shouldSubmit({ + killSwitchScope: candidate.killSwitchScope, + predictedGateVerdict: candidate.handoffPacket.selfReviewVerdict.predictedGateVerdict, + slopAssessment: candidate.handoffPacket.selfReviewVerdict.slopAssessment, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + }); + + return { allow: gateDecision.allow, reasons: gateDecision.reasons, circuitBreakerTripped: false }; +} diff --git a/packages/gittensory-engine/src/miner/loop-reentry-policy.ts b/packages/gittensory-engine/src/miner/loop-reentry-policy.ts index 381ac416d0..804ac52f33 100644 --- a/packages/gittensory-engine/src/miner/loop-reentry-policy.ts +++ b/packages/gittensory-engine/src/miner/loop-reentry-policy.ts @@ -14,6 +14,16 @@ // re-entries may fire in a rolling hour or across the whole session. // Both reasons are collected (not short-circuited) so a caller logging the decision sees every ceiling that // was hit, not just the first one checked. +// +// KILL-SWITCH (#2339): checked FIRST, before any other logic -- flipping the kill-switch must halt any pending +// re-entry immediately, the same way it halts the Governor chokepoint (#2340). Reuses +// `isMinerKillSwitchActive` (kill-switch.ts, #2341) directly -- the identical shared helper +// `submission-gate.ts`'s `shouldSubmit` consults, per #2339's own "single shared helper, not duplicated per +// call site" deliverable. Unlike the reasons above, the kill-switch check DOES short-circuit (an active kill- +// switch is the only reason reported) -- "as their FIRST guard, before any other logic" reads as "don't even +// evaluate the rest," not "collect this alongside the rest." + +import { isMinerKillSwitchActive, type MinerKillSwitchScope } from "../governor/kill-switch.js"; /** The terminal outcome that just resolved for the repo the caller is considering re-entering on. */ export type LoopReentryOutcome = "merged" | "disengaged" | "other"; @@ -23,6 +33,8 @@ export const DEFAULT_MAX_REENTRIES_PER_HOUR = 4; export const DEFAULT_MAX_REENTRIES_PER_SESSION = 20; export type LoopReentryCandidate = { + /** Checked FIRST, before any other field below -- see the module doc comment's KILL-SWITCH section. */ + killSwitchScope: MinerKillSwitchScope; repoFullName: string; outcome: LoopReentryOutcome; /** Caller-computed count of CONSECUTIVE `"disengaged"` outcomes for this repo, ending with (and including, @@ -48,9 +60,14 @@ export type LoopReentryDecision = { /** * Decide whether the loop may re-enter discovery for this repo. Pure; identical inputs always yield the * identical decision. `outcome === "merged"` alone never bypasses the rate/session cap -- a healthy repo can - * still be rate-limited if the operator-wide ceiling is already spent. + * still be rate-limited if the operator-wide ceiling is already spent. The kill-switch is checked FIRST and + * short-circuits everything else -- an active kill-switch blocks unconditionally. */ export function shouldReenter(candidate: LoopReentryCandidate): LoopReentryDecision { + if (isMinerKillSwitchActive(candidate.killSwitchScope)) { + return { reenter: false, reasons: [`${candidate.killSwitchScope}_kill_switch_active`] }; + } + const reasons: string[] = []; const maxConsecutiveDisengagements = candidate.maxConsecutiveDisengagements ?? DEFAULT_MAX_CONSECUTIVE_DISENGAGEMENTS; const maxReentriesPerHour = candidate.maxReentriesPerHour ?? DEFAULT_MAX_REENTRIES_PER_HOUR; diff --git a/packages/gittensory-engine/src/miner/submission-gate.ts b/packages/gittensory-engine/src/miner/submission-gate.ts index ac1849a20a..2e7d140600 100644 --- a/packages/gittensory-engine/src/miner/submission-gate.ts +++ b/packages/gittensory-engine/src/miner/submission-gate.ts @@ -23,7 +23,16 @@ // the Governor chokepoint's own dry-run/live action-mode dial (#2342), which gates autonomous WRITING at all // for a repo. `"observe"` here is specifically for safely calibrating the predicted-gate/slop thresholds // against live traffic before ever trusting them to gate a real submission. +// +// KILL-SWITCH (#2339): checked FIRST, before any other logic -- flipping the kill-switch must halt this +// chokepoint immediately, the same way it halts the Governor chokepoint (#2340). Reuses +// `isMinerKillSwitchActive` (kill-switch.ts, #2341) directly rather than a bespoke wrapper -- the exact "single +// shared helper, not duplicated per call site" #2339's own deliverable calls for; `loop-reentry-policy.ts`'s +// `shouldReenter` consults the identical function. `killSwitchScope` is REQUIRED (not optional-with-a- +// permissive-default) so a caller cannot forget to resolve and pass it -- the same fail-closed-by-construction +// discipline as every other required field here. +import { isMinerKillSwitchActive, type MinerKillSwitchScope } from "../governor/kill-switch.js"; import type { PredictedGateVerdict } from "../predicted-gate.js"; import type { SelfReviewSlopAssessment, SelfReviewSlopBand } from "./self-review-adapter.js"; @@ -51,6 +60,8 @@ export function isSlopBandWithinThreshold(band: SelfReviewSlopBand, threshold: S export type SubmissionGateMode = "observe" | "enforce"; export type SubmissionGateCandidate = { + /** Checked FIRST, before any other field below -- see the module doc comment's KILL-SWITCH section. */ + killSwitchScope: MinerKillSwitchScope; /** `null` means the predictor was unreachable or errored -- fails closed, exactly like a genuine non-passing * verdict, never treated as "no opinion, so allow". */ predictedGateVerdict: PredictedGateVerdict | null; @@ -92,9 +103,15 @@ function evaluateSubmissionSignals(candidate: SubmissionGateCandidate): string[] /** * THE gate: build (or invoke) `gittensory_open_pr`'s action spec ONLY when this returns `allow: true`. Requires * BOTH a clean predicted-gate pass AND a slop band at or under the configured threshold; any missing signal, or - * `mode: "observe"`, forces `allow: false`. Pure; identical inputs always yield the identical decision. + * `mode: "observe"`, forces `allow: false`. The kill-switch is checked FIRST, before any other logic -- an + * active kill-switch blocks unconditionally, regardless of otherwise-passing signals. Pure; identical inputs + * always yield the identical decision. */ export function shouldSubmit(candidate: SubmissionGateCandidate): SubmissionGateDecision { + if (isMinerKillSwitchActive(candidate.killSwitchScope)) { + return { allow: false, reasons: [`${candidate.killSwitchScope}_kill_switch_active`] }; + } + const reasons = evaluateSubmissionSignals(candidate); const signalsPass = reasons.length === 0; diff --git a/packages/gittensory-engine/test/harness-submission-trigger.test.ts b/packages/gittensory-engine/test/harness-submission-trigger.test.ts new file mode 100644 index 0000000000..2f5e957ae2 --- /dev/null +++ b/packages/gittensory-engine/test/harness-submission-trigger.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, + evaluateHarnessSubmissionTrigger, + type HandoffPacket, + type HarnessSubmissionTriggerCandidate, + type PredictedGateVerdict, + type SelfReviewSlopAssessment, + type SelfReviewVerdict, +} from "../dist/index.js"; + +function passingVerdictFields(): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "t", + summary: "s", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function failingVerdictFields(): PredictedGateVerdict { + return { ...passingVerdictFields(), conclusion: "failure", blockers: [{ code: "duplicate_pr_risk", title: "t", detail: "d" }] }; +} + +function slop(band: SelfReviewSlopAssessment["band"]): SelfReviewSlopAssessment { + return { slopRisk: 0, band, findings: [] }; +} + +function selfReviewVerdict(overrides: Partial = {}): SelfReviewVerdict { + return { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: slop("clean"), + changedPaths: ["src/upload.ts"], + passesPredictedGate: true, + ...overrides, + }; +} + +function handoffPacket(verdictOverrides: Partial = {}): HandoffPacket { + return { + worktreePath: "/tmp/attempt-1", + diffSummary: "added retry logic", + selfReviewVerdict: selfReviewVerdict(verdictOverrides), + attemptLogReference: "attempt-1", + }; +} + +function baseCandidate(overrides: Partial = {}): HarnessSubmissionTriggerCandidate { + return { + killSwitchScope: "none", + handoffPacket: handoffPacket(), + slopThreshold: "low", + mode: "enforce", + consecutiveGateBlocks: 0, + ...overrides, + }; +} + +test("barrel: the public entrypoint re-exports the harness submission trigger (#2337)", () => { + assert.equal(typeof evaluateHarnessSubmissionTrigger, "function"); + assert.equal(typeof DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, "number"); +}); + +test("a passing handoff with the circuit breaker well clear allows, forwarding shouldSubmit's own empty reasons", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate()); + assert.deepEqual(decision, { allow: true, reasons: [], circuitBreakerTripped: false }); +}); + +test("kill-switch (#2339): forwarded to shouldSubmit's own check, blocking an otherwise-clean handoff below the circuit breaker", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ killSwitchScope: "global" })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.deepEqual(decision.reasons, ["global_kill_switch_active"]); +}); + +test("circuit breaker: N consecutive blocks trips it, refusing even an otherwise-clean handoff -- never consulting shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: 3, maxConsecutiveGateBlocks: 3 })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, true); + assert.deepEqual(decision.reasons, ["circuit_breaker_tripped_after_consecutive_blocks:3>=3"]); +}); + +test("circuit breaker: below the ceiling still proceeds to consult shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: 2, maxConsecutiveGateBlocks: 3 })); + assert.equal(decision.allow, true); + assert.equal(decision.circuitBreakerTripped, false); +}); + +test("default circuit-breaker ceiling applies when the candidate omits its own override", () => { + const justUnder = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS - 1 })); + assert.equal(justUnder.allow, true); + + const atDefault = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS })); + assert.equal(atDefault.allow, false); + assert.equal(atDefault.circuitBreakerTripped, true); +}); + +test("a handoff whose verdict fails predicted-gate is blocked by shouldSubmit, not the circuit breaker -- defense in depth against a malformed handoff", () => { + const decision = evaluateHarnessSubmissionTrigger( + baseCandidate({ handoffPacket: handoffPacket({ predictedGateVerdict: failingVerdictFields(), passesPredictedGate: false }) }), + ); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.ok(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing"))); +}); + +test("a handoff whose slop assessment exceeds the configured threshold is blocked by shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger( + baseCandidate({ handoffPacket: handoffPacket({ slopAssessment: slop("high") }), slopThreshold: "low" }), + ); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["slop_band_exceeds_threshold:high>low"]); +}); + +test("observe mode forces allow: false even for an otherwise-clean handoff, below the circuit breaker", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ mode: "observe" })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_allowed"]); +}); diff --git a/packages/gittensory-engine/test/loop-reentry-policy.test.ts b/packages/gittensory-engine/test/loop-reentry-policy.test.ts index af86561c18..14b6eeff09 100644 --- a/packages/gittensory-engine/test/loop-reentry-policy.test.ts +++ b/packages/gittensory-engine/test/loop-reentry-policy.test.ts @@ -11,6 +11,7 @@ import { function baseCandidate(overrides: Partial = {}): LoopReentryCandidate { return { + killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", consecutiveDisengagements: 0, @@ -30,6 +31,23 @@ test("a merged outcome with every counter well within limits re-enters cleanly", assert.deepEqual(decision, { reenter: true, reasons: [] }); }); +test("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every counter otherwise clear", () => { + const decision = shouldReenter(baseCandidate({ killSwitchScope: "global" })); + assert.deepEqual(decision, { reenter: false, reasons: ["global_kill_switch_active"] }); +}); + +test("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before the circuit breaker or rate caps", () => { + const decision = shouldReenter( + baseCandidate({ killSwitchScope: "repo", outcome: "disengaged", consecutiveDisengagements: 99 }), + ); + assert.deepEqual(decision, { reenter: false, reasons: ["repo_kill_switch_active"] }); +}); + +test("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- other checks are still evaluated normally", () => { + const decision = shouldReenter(baseCandidate({ killSwitchScope: "none" })); + assert.equal(decision.reenter, true); +}); + test("an 'other' outcome (neither merged nor disengaged) is never subject to the per-repo circuit breaker", () => { const decision = shouldReenter(baseCandidate({ outcome: "other" })); assert.deepEqual(decision, { reenter: true, reasons: [] }); diff --git a/packages/gittensory-engine/test/submission-gate.test.ts b/packages/gittensory-engine/test/submission-gate.test.ts index a070684bc3..c51fe30f3e 100644 --- a/packages/gittensory-engine/test/submission-gate.test.ts +++ b/packages/gittensory-engine/test/submission-gate.test.ts @@ -51,6 +51,7 @@ function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment function baseCandidate(overrides: Partial = {}): SubmissionGateCandidate { return { + killSwitchScope: "none", predictedGateVerdict: passingVerdict(), slopAssessment: slop("clean"), slopThreshold: "low", @@ -70,6 +71,21 @@ test("pass/pass: a clean predicted-gate pass with slop under threshold allows, w assert.deepEqual(decision, { allow: true, reasons: [] }); }); +test("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every other signal otherwise passing", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "global" })); + assert.deepEqual(decision, { allow: false, reasons: ["global_kill_switch_active"] }); +}); + +test("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before any signal or mode logic", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "repo", mode: "observe" })); + assert.deepEqual(decision, { allow: false, reasons: ["repo_kill_switch_active"] }); +}); + +test("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- signals are still evaluated normally", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "none" })); + assert.equal(decision.allow, true); +}); + test("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => { const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() })); assert.equal(decision.allow, false); diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.d.ts b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts new file mode 100644 index 0000000000..1a223030a0 --- /dev/null +++ b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts @@ -0,0 +1,46 @@ +export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT: "harness_submission_trigger_decision"; + +export type HarnessSubmissionSlopBand = "clean" | "low" | "elevated" | "high"; +export type HarnessSubmissionMode = "observe" | "enforce"; +export type HarnessSubmissionKillSwitchScope = "global" | "repo" | "none"; + +export type HarnessSubmissionCandidateInput = { + /** Forwarded to shouldSubmit's own kill-switch check (#2339). */ + killSwitchScope: HarnessSubmissionKillSwitchScope; + repoFullName: string; + handoffPacket: { + worktreePath: string; + branchRef?: string; + diffSummary: string; + selfReviewVerdict: unknown; + attemptLogReference: string; + }; + slopThreshold: HarnessSubmissionSlopBand; + mode: HarnessSubmissionMode; + maxConsecutiveGateBlocks?: number; +}; + +export interface HarnessSubmissionEventLedger { + appendEvent(event: { type: string; repoFullName?: string; payload: Record }): { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; + readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ type: string; repoFullName?: string | null; payload?: Record; createdAt: string }>; +} + +export type HarnessSubmissionDeps = { + eventLedger: HarnessSubmissionEventLedger; + sessionStartMs?: number; +}; + +export type HarnessSubmissionDecision = { + allow: boolean; + reasons: string[]; + circuitBreakerTripped: boolean; +}; + +export type HarnessSubmissionResult = { + decision: HarnessSubmissionDecision; + event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; +}; + +export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number; + +export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult; diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.js b/packages/gittensory-miner/lib/harness-submission-trigger.js new file mode 100644 index 0000000000..9eb580aa6f --- /dev/null +++ b/packages/gittensory-miner/lib/harness-submission-trigger.js @@ -0,0 +1,84 @@ +import { evaluateHarnessSubmissionTrigger } from "@jsonbored/gittensory-engine"; + +// Harness submission-gate wiring orchestrator (#2337): the real-IO half of connecting the gated-submission +// decision (`shouldSubmit`, wrapped by `evaluateHarnessSubmissionTrigger`, @jsonbored/gittensory-engine) to a +// real driving loop's own handoff signal. Reads the session's recent decision history to compute the +// consecutive-block circuit-breaker tally, consults the pure decision, and always records exactly one audit +// event -- regardless of outcome, so a paused-pending-human-review session leaves a full trail of why. +// +// NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off on the wiring before this +// ships to any default-on profile" deliverable. A real call site (root-side server/CLI integration) invokes +// this function with a real `HandoffPacket`; on `allow: true` it may then build the `open_pr` local-write spec +// itself -- this module does not, and cannot, do that (the spec builder lives in the private root `src/` tree, +// unreachable from this package -- same cross-package-boundary reason self-review-adapter.ts's slop injection +// exists). +// +// SESSION-SCOPED, NOT PER-REPO: the circuit breaker's own "pauses the run entirely" wording means the tally is +// counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop- +// reentry circuit breaker, which is deliberately per-repo (a rejection streak on one repo must not pause +// unrelated repos). + +export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision"; + +/** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most + * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not + * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */ +export function countConsecutiveGateBlocks(eventLedger, sinceMs) { + const decisions = eventLedger + .readEvents({}) + .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs); + let count = 0; + for (let i = decisions.length - 1; i >= 0; i -= 1) { + if (decisions[i].payload?.allow === true) break; + count += 1; + } + return count; +} + +/** + * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute + * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a + * malformed candidate or missing required dependency. + * + * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: object, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number }} candidate + * @param {{ eventLedger: object, sessionStartMs?: number }} deps + */ +export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") throw new Error("invalid_handoff_packet"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_harness_submission_deps"); + const { eventLedger, sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + + const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs); + + const decision = evaluateHarnessSubmissionTrigger({ + killSwitchScope: candidate.killSwitchScope, + handoffPacket: candidate.handoffPacket, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + consecutiveGateBlocks, + maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks, + }); + + const event = eventLedger.appendEvent({ + type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + allow: decision.allow, + reasons: decision.reasons, + circuitBreakerTripped: decision.circuitBreakerTripped, + consecutiveGateBlocks, + attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null, + }, + }); + + return { decision, event }; +} diff --git a/packages/gittensory-miner/lib/loop-reentry.d.ts b/packages/gittensory-miner/lib/loop-reentry.d.ts index 75fcda5a34..ab4b3721bf 100644 --- a/packages/gittensory-miner/lib/loop-reentry.d.ts +++ b/packages/gittensory-miner/lib/loop-reentry.d.ts @@ -1,8 +1,11 @@ export const LOOP_REENTRY_DECISION_EVENT: "loop_reentry_decision"; export type LoopReentryOutcome = "merged" | "disengaged" | "other"; +export type LoopReentryKillSwitchScope = "global" | "repo" | "none"; export type LoopReentryCandidateInput = { + /** Checked FIRST by the pure `shouldReenter` policy, before any other logic. */ + killSwitchScope: LoopReentryKillSwitchScope; repoFullName: string; outcome: LoopReentryOutcome; maxConsecutiveDisengagements?: number; diff --git a/packages/gittensory-miner/lib/loop-reentry.js b/packages/gittensory-miner/lib/loop-reentry.js index 4fe248a315..c89821fffd 100644 --- a/packages/gittensory-miner/lib/loop-reentry.js +++ b/packages/gittensory-miner/lib/loop-reentry.js @@ -56,11 +56,12 @@ export function countReentriesSince(eventLedger, sinceMs) { * event. Fails closed (throws) on a malformed candidate or missing required dependency, mirroring * `recordManagePollSnapshot`'s own validation style. * - * @param {{ repoFullName: string, outcome: "merged"|"disengaged"|"other", maxConsecutiveDisengagements?: number, maxReentriesPerHour?: number, maxReentriesPerSession?: number }} candidate + * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, outcome: "merged"|"disengaged"|"other", maxConsecutiveDisengagements?: number, maxReentriesPerHour?: number, maxReentriesPerSession?: number }} candidate * @param {{ eventLedger: object, portfolioQueue: object, runState?: object, nowMs?: number, sessionStartMs?: number }} deps */ export function attemptLoopReentry(candidate, deps) { if (!candidate || typeof candidate !== "object") throw new Error("invalid_loop_reentry_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; if (!repoFullName) throw new Error("invalid_repo_full_name"); if (!["merged", "disengaged", "other"].includes(candidate.outcome)) throw new Error("invalid_outcome"); @@ -79,6 +80,7 @@ export function attemptLoopReentry(candidate, deps) { const reentriesThisSession = countReentriesSince(eventLedger, sessionStartMs); const decision = shouldReenter({ + killSwitchScope: candidate.killSwitchScope, repoFullName, outcome: candidate.outcome, consecutiveDisengagements, @@ -101,6 +103,7 @@ export function attemptLoopReentry(candidate, deps) { type: LOOP_REENTRY_DECISION_EVENT, repoFullName, payload: { + killSwitchScope: candidate.killSwitchScope, outcome: candidate.outcome, reentered: decision.reenter, reasons: decision.reasons, diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7b36ec1f59..a441ae18a1 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" + "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" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-harness-submission-trigger.test.ts b/test/unit/miner-harness-submission-trigger.test.ts new file mode 100644 index 0000000000..77df563f26 --- /dev/null +++ b/test/unit/miner-harness-submission-trigger.test.ts @@ -0,0 +1,287 @@ +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 { evaluateAndRecordHarnessSubmissionTrigger, countConsecutiveGateBlocks, HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT } from "../../packages/gittensory-miner/lib/harness-submission-trigger.js"; +import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; + +const roots: string[] = []; +const closers: Array<{ close(): void }> = []; + +function tempEventLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-harness-trigger-")); + roots.push(root); + const ledger = initEventLedger(join(root, "db.sqlite3")); + closers.push(ledger); + return ledger; +} + +function passingVerdictFields() { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "t", + summary: "s", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function handoffPacket(overrides: Record = {}) { + return { + worktreePath: "/tmp/attempt-1", + diffSummary: "added retry logic", + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: ["src/upload.ts"], + passesPredictedGate: true, + }, + attemptLogReference: "attempt-1", + ...overrides, + }; +} + +afterEach(() => { + for (const closer of closers.splice(0)) closer.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("evaluateAndRecordHarnessSubmissionTrigger (#2337)", () => { + it("full candidate -> gate-check -> submit cycle: a clean handoff allows and records one audit event", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision).toEqual({ allow: true, reasons: [], circuitBreakerTripped: false }); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe(HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT); + expect(events[0]?.payload).toMatchObject({ allow: true, circuitBreakerTripped: false, attemptLogReference: "attempt-1" }); + }); + + it("kill-switch (#2339): blocks an otherwise-clean handoff unconditionally, and the block is recorded with the scope that caused it", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "global", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"], circuitBreakerTripped: false }); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events[0]?.payload).toMatchObject({ killSwitchScope: "global", allow: false }); + }); + + it("full candidate -> gate-check -> correctly-blocked cycle: a non-passing handoff is blocked, and the block itself is recorded", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [{ code: "duplicate_pr_risk", title: "t", detail: "d" }] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: ["src/upload.ts"], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.circuitBreakerTripped).toBe(false); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events[0]?.payload).toMatchObject({ allow: false }); + }); + + it("circuit breaker: after enough consecutive blocked decisions this session, the run pauses even for an otherwise-clean handoff", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + // Three consecutive blocked decisions, each recorded to the real session history. + for (let i = 0; i < 3; i += 1) { + const blocked = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce", maxConsecutiveGateBlocks: 3 }, + { eventLedger }, + ); + expect(blocked.decision.allow).toBe(false); + } + expect(countConsecutiveGateBlocks(eventLedger, 0)).toBe(3); + + // A fourth candidate, this time a genuinely clean handoff -- the circuit breaker still pauses it. + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce", maxConsecutiveGateBlocks: 3 }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.circuitBreakerTripped).toBe(true); + expect(result.decision.reasons).toEqual(["circuit_breaker_tripped_after_consecutive_blocks:3>=3"]); + }); + + it("a single allowed decision resets the consecutive-block streak, un-pausing the next candidate", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce" }, { eventLedger }); + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, { eventLedger }); + + expect(countConsecutiveGateBlocks(eventLedger, 0)).toBe(0); + }); + + it("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => { + const eventLedger = tempEventLedger(); + const unreachableHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: null, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: unreachableHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toContain("predicted_gate_unavailable"); + }); + + it("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => { + const eventLedger = tempEventLedger(); + const erroredSlopHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: null, + changedPaths: [], + passesPredictedGate: true, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: erroredSlopHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toContain("slop_assessment_unavailable"); + }); + + it("a handoff whose slop assessment exceeds the configured threshold is blocked, with the band/threshold pair in the reason", () => { + const eventLedger = tempEventLedger(); + const highSlopHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: { slopRisk: 0, band: "high", findings: [] }, + changedPaths: [], + passesPredictedGate: true, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: highSlopHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["slop_band_exceeds_threshold:high>low"]); + }); + + it("observe mode: a would-have-allowed decision still forces allow: false, with a distinct reason from a would-have-blocked one", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "observe" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["observe_mode_active:would_have_allowed"]); + }); + + it("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: null, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "observe" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]); + }); + + it("records a null attemptLogReference in the audit payload when the handoff packet omits one", () => { + const eventLedger = tempEventLedger(); + const withoutReference = handoffPacket({ attemptLogReference: undefined }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: withoutReference, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.event.payload.attemptLogReference).toBeNull(); + }); + + it("fails closed on a malformed candidate or missing dependency rather than silently allowing", () => { + const eventLedger = tempEventLedger(); + expect(() => evaluateAndRecordHarnessSubmissionTrigger(null as never, { eventLedger })).toThrow("invalid_harness_submission_candidate"); + expect(() => evaluateAndRecordHarnessSubmissionTrigger({ repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, { eventLedger })).toThrow( + "invalid_kill_switch_scope", + ); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "bogus", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, { eventLedger }), + ).toThrow("invalid_kill_switch_scope"); + expect(() => evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", handoffPacket: handoffPacket() } as never, { eventLedger })).toThrow( + "invalid_repo_full_name", + ); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets" } as never, { eventLedger }), + ).toThrow("invalid_handoff_packet"); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, null as never), + ).toThrow("invalid_harness_submission_deps"); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, {} as never), + ).toThrow("invalid_event_ledger"); + }); +}); diff --git a/test/unit/miner-loop-reentry.test.ts b/test/unit/miner-loop-reentry.test.ts index 7f3a63c0b6..23888effd1 100644 --- a/test/unit/miner-loop-reentry.test.ts +++ b/test/unit/miner-loop-reentry.test.ts @@ -53,7 +53,7 @@ describe("attemptLoopReentry (#2338)", () => { portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-42" }); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged" }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, runState }, ); @@ -84,7 +84,7 @@ describe("attemptLoopReentry (#2338)", () => { expect(countConsecutiveDisengagements(eventLedger, "acme/widgets")).toBe(3); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "disengaged" }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "disengaged" }, { eventLedger, portfolioQueue, runState }, ); @@ -129,7 +129,7 @@ describe("attemptLoopReentry (#2338)", () => { expect(countReentriesSince(eventLedger, now - 60 * 60 * 1000)).toBe(4); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 4 }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 4 }, { eventLedger, portfolioQueue, runState, nowMs: now }, ); @@ -150,7 +150,7 @@ describe("attemptLoopReentry (#2338)", () => { } const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 1_000, maxReentriesPerSession: 20 }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 1_000, maxReentriesPerSession: 20 }, { eventLedger, portfolioQueue, runState, sessionStartMs: 0 }, ); @@ -164,12 +164,32 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); expect(() => attemptLoopReentry(null as never, { eventLedger, portfolioQueue })).toThrow("invalid_loop_reentry_candidate"); - expect(() => attemptLoopReentry({ outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); - expect(() => attemptLoopReentry({ repoFullName: "", outcome: "merged" }, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "bogus" as never }, { eventLedger, portfolioQueue })).toThrow("invalid_outcome"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, null as never)).toThrow("invalid_loop_reentry_deps"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger } as never)).toThrow("invalid_portfolio_queue"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { portfolioQueue } as never)).toThrow("invalid_event_ledger"); + expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_kill_switch_scope"); + expect(() => attemptLoopReentry({ killSwitchScope: "bogus", repoFullName: "acme/widgets", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_kill_switch_scope"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "", outcome: "merged" }, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "bogus" as never }, { eventLedger, portfolioQueue })).toThrow("invalid_outcome"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, null as never)).toThrow("invalid_loop_reentry_deps"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger } as never)).toThrow("invalid_portfolio_queue"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { portfolioQueue } as never)).toThrow("invalid_event_ledger"); + }); + + it("kill-switch (#2339): an active kill-switch blocks re-entry unconditionally, without dequeuing or moving run-state", () => { + const eventLedger = tempEventLedger(); + const portfolioQueue = tempPortfolioQueue(); + const runState = tempRunState(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); + + const result = attemptLoopReentry( + { killSwitchScope: "global", repoFullName: "acme/widgets", outcome: "merged" }, + { eventLedger, portfolioQueue, runState }, + ); + + expect(result.decision).toEqual({ reenter: false, reasons: ["global_kill_switch_active"] }); + expect(result.dequeued).toBeNull(); + expect(runState.getRunState("acme/widgets")).toBeNull(); + expect(portfolioQueue.listQueue("acme/widgets")).toHaveLength(1); + expect(result.event.payload).toMatchObject({ killSwitchScope: "global" }); }); it("threads a caller-supplied loopSummary verbatim into the audit event payload for traceability", () => { @@ -178,7 +198,7 @@ describe("attemptLoopReentry (#2338)", () => { portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); const loopSummary = { sinceSeq: 10, lastSeq: 42, events: { total: 3, byType: { pr_outcome: 1 } }, queue: { total: 1, byStatus: { queued: 1 } }, runState: "idle" }; - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, loopSummary }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, loopSummary }); expect(result.event.payload.loopSummary).toEqual(loopSummary); }); @@ -188,7 +208,7 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); expect(result.event.payload.loopSummary).toBeNull(); }); @@ -198,7 +218,7 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); expect(result.decision.reenter).toBe(true); expect(result.dequeued?.identifier).toBe("issue-1"); }); diff --git a/test/unit/miner-submission-gate.test.ts b/test/unit/miner-submission-gate.test.ts new file mode 100644 index 0000000000..7e14016f0b --- /dev/null +++ b/test/unit/miner-submission-gate.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + isSlopBandWithinThreshold, + shouldSubmit, + SUBMISSION_GATE_PASSING_CONCLUSION, + type PredictedGateVerdict, + type SelfReviewSlopAssessment, + type SelfReviewSlopBand, + type SubmissionGateCandidate, +} from "../../packages/gittensory-engine/src/index"; + +function passingVerdict(): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "Predicted gate: pass", + summary: "Every check is expected to pass.", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function failingVerdict(blockers: PredictedGateVerdict["blockers"] = [{ code: "duplicate_pr_risk", title: "Likely duplicate", detail: "Matches an existing open PR." }]): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "failure", + title: "Predicted gate: fail", + summary: "At least one check is expected to fail.", + readinessScore: 15, + confirmedContributor: undefined, + blockers, + warnings: [], + funnel: null, + note: "", + }; +} + +function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment { + return { slopRisk, band, findings: [] }; +} + +function baseCandidate(overrides: Partial = {}): SubmissionGateCandidate { + return { + killSwitchScope: "none", + predictedGateVerdict: passingVerdict(), + slopAssessment: slop("clean"), + slopThreshold: "low", + mode: "enforce", + ...overrides, + }; +} + +describe("shouldSubmit (#2336)", () => { + it("barrel: the public entrypoint re-exports the submission gate", () => { + expect(typeof shouldSubmit).toBe("function"); + expect(typeof isSlopBandWithinThreshold).toBe("function"); + expect(SUBMISSION_GATE_PASSING_CONCLUSION).toBe("success"); + }); + + it("pass/pass: a clean predicted-gate pass with slop under threshold allows, with no reasons", () => { + const decision = shouldSubmit(baseCandidate()); + expect(decision).toEqual({ allow: true, reasons: [] }); + }); + + it("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every other signal otherwise passing", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "global" })); + expect(decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"] }); + }); + + it("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before any signal or mode logic", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "repo", mode: "observe" })); + expect(decision).toEqual({ allow: false, reasons: ["repo_kill_switch_active"] }); + }); + + it("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- signals are still evaluated normally", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "none" })); + expect(decision.allow).toBe(true); + }); + + it("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toHaveLength(1); + expect(decision.reasons[0]).toMatch(/^predicted_gate_not_passing:failure:duplicate_pr_risk$/); + }); + + it("fail/pass: a non-passing verdict with NO blockers listed still formats a reason, without a dangling separator", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict([]) })); + expect(decision.reasons[0]).toBe("predicted_gate_not_passing:failure"); + }); + + it("pass/fail: a clean predicted-gate pass blocks when slop exceeds the configured threshold", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: slop("high"), slopThreshold: "low" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["slop_band_exceeds_threshold:high>low"]); + }); + + it("both-fail: a non-passing verdict AND over-threshold slop blocks with both reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict(), slopAssessment: slop("high"), slopThreshold: "low" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toHaveLength(2); + expect(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing"))).toBe(true); + expect(decision.reasons.some((r) => r.startsWith("slop_band_exceeds_threshold"))).toBe(true); + }); + + it("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["predicted_gate_unavailable"]); + }); + + it("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["slop_assessment_unavailable"]); + }); + + it("fail-closed: both signals missing blocks with both unavailable reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null, slopAssessment: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["predicted_gate_unavailable", "slop_assessment_unavailable"]); + }); + + it("observe mode: forces allow: false even for signals that would otherwise cleanly pass", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["observe_mode_active:would_have_allowed"]); + }); + + it("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe", predictedGateVerdict: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]); + }); +}); + +describe("isSlopBandWithinThreshold (#2336)", () => { + it("a band exactly equal to the threshold passes (inclusive boundary)", () => { + expect(isSlopBandWithinThreshold("elevated", "elevated")).toBe(true); + }); + + it("a band one severity level under the threshold passes", () => { + expect(isSlopBandWithinThreshold("low", "elevated")).toBe(true); + }); + + it("a band one severity level over the threshold fails", () => { + expect(isSlopBandWithinThreshold("high", "elevated")).toBe(false); + }); + + it("the full clean..high ordering is respected end to end", () => { + const order: SelfReviewSlopBand[] = ["clean", "low", "elevated", "high"]; + for (let i = 0; i < order.length; i += 1) { + for (let j = 0; j < order.length; j += 1) { + const band = order[i] as SelfReviewSlopBand; + const threshold = order[j] as SelfReviewSlopBand; + expect(isSlopBandWithinThreshold(band, threshold)).toBe(i <= j); + } + } + }); +});