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
2 changes: 1 addition & 1 deletion orb-manifest.json
Original file line number Diff line number Diff line change
@@ -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."
}
6 changes: 6 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions packages/gittensory-engine/src/miner/harness-submission-trigger.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
19 changes: 18 additions & 1 deletion packages/gittensory-engine/src/miner/loop-reentry-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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;
Expand Down
19 changes: 18 additions & 1 deletion packages/gittensory-engine/src/miner/submission-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
130 changes: 130 additions & 0 deletions packages/gittensory-engine/test/harness-submission-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): SelfReviewVerdict {
return {
predictedGateVerdict: passingVerdictFields(),
slopAssessment: slop("clean"),
changedPaths: ["src/upload.ts"],
passesPredictedGate: true,
...overrides,
};
}

function handoffPacket(verdictOverrides: Partial<SelfReviewVerdict> = {}): HandoffPacket {
return {
worktreePath: "/tmp/attempt-1",
diffSummary: "added retry logic",
selfReviewVerdict: selfReviewVerdict(verdictOverrides),
attemptLogReference: "attempt-1",
};
}

function baseCandidate(overrides: Partial<HarnessSubmissionTriggerCandidate> = {}): 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"]);
});
18 changes: 18 additions & 0 deletions packages/gittensory-engine/test/loop-reentry-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {

function baseCandidate(overrides: Partial<LoopReentryCandidate> = {}): LoopReentryCandidate {
return {
killSwitchScope: "none",
repoFullName: "acme/widgets",
outcome: "merged",
consecutiveDisengagements: 0,
Expand All @@ -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: [] });
Expand Down
Loading