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
23 changes: 23 additions & 0 deletions packages/gittensory-miner/lib/harness-submission-trigger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,26 @@ export type HarnessSubmissionResult = {
export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number;

export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult;

/** The exact input shape buildOpenPrSpec (root src/mcp/local-write-tools.ts) expects. */
export type OpenPrInput = {
repoFullName: string;
base: string;
head: string;
title: string;
body: string;
draft: boolean;
};

export type PrepareOpenPrSubmissionCandidate = HarnessSubmissionCandidateInput & {
base: string;
title: string;
body?: string;
draft?: boolean;
};

export type PrepareOpenPrSubmissionResult =
| { ready: true; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"]; openPrInput: OpenPrInput }
| { ready: false; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"] };

export function prepareOpenPrSubmission(candidate: PrepareOpenPrSubmissionCandidate, deps: HarnessSubmissionDeps): PrepareOpenPrSubmissionResult;
64 changes: 59 additions & 5 deletions packages/gittensory-miner/lib/harness-submission-trigger.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import { evaluateHarnessSubmissionTrigger } from "@jsonbored/gittensory-engine";
// 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).
// ships to any default-on profile" deliverable. `prepareOpenPrSubmission` below is the call site up to the
// cross-package boundary: on `allow: true` it shapes the exact input `buildOpenPrSpec` (root
// `src/mcp/local-write-tools.ts`) needs -- but does not, and cannot, call that function itself, since 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). A real root-side/MCP call site (e.g. the existing
// `gittensory_open_pr` tool, src/mcp/server.ts) takes `openPrInput` from a `ready: true` result and passes it
// to `buildOpenPrSpec` (or the equivalent tool call) to actually produce the runnable local-write spec. The
// CLI/driver entrypoint that instantiates a real `CodingAgentDriver` and calls `runIterateLoop` end to end with
// live credentials does not exist yet in this package -- that is separate, larger scope from this decision-to-
// payload bridge.
//
// 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-
Expand Down Expand Up @@ -82,3 +87,52 @@ export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) {

return { decision, event };
}

/**
* Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input
* shape `buildOpenPrSpec` expects (repoFullName/base/head/title/body/draft). On `allow: true` returns
* `{ ready: true, decision, event, openPrInput }`; otherwise `{ ready: false, decision, event }` -- the block
* reasons are on `decision.reasons` and already on the ledger via the wrapped call either way. Does NOT call
* `buildOpenPrSpec` itself (see this module's own doc comment for why it cannot) -- a real root-side/MCP call
* site takes `openPrInput` from a `ready: true` result and passes it to that function or the equivalent
* `gittensory_open_pr` MCP tool.
*
* Fails closed (throws) on a malformed candidate, mirroring evaluateAndRecordHarnessSubmissionTrigger's own
* validation -- a missing PR title/base is a caller bug that must never silently degrade into a garbage spec.
* The one field evaluateAndRecordHarnessSubmissionTrigger does NOT itself require -- handoffPacket.branchRef,
* optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here,
* but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a
* blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked.
*
* @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: { branchRef?: string, [key: string]: unknown }, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number, base: string, title: string, body?: string, draft?: boolean }} candidate
* @param {{ eventLedger: object, sessionStartMs?: number }} deps
*/
export function prepareOpenPrSubmission(candidate, deps) {
if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate");
const base = typeof candidate.base === "string" ? candidate.base.trim() : "";
if (!base) throw new Error("invalid_pr_base");
const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
if (!title) throw new Error("invalid_pr_title");

const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps);
if (!decision.allow) return { ready: false, decision, event };

// Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a
// well-formed object -- safe to read .branchRef directly.
const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : "";
if (!head) throw new Error("invalid_pr_head_branch");

return {
ready: true,
decision,
event,
openPrInput: {
repoFullName: candidate.repoFullName.trim(),
base,
head,
title,
body: typeof candidate.body === "string" ? candidate.body : "",
draft: candidate.draft === true,
},
};
}
152 changes: 151 additions & 1 deletion test/unit/miner-harness-submission-trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ 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 {
evaluateAndRecordHarnessSubmissionTrigger,
countConsecutiveGateBlocks,
prepareOpenPrSubmission,
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[] = [];
Expand Down Expand Up @@ -285,3 +290,148 @@ describe("evaluateAndRecordHarnessSubmissionTrigger (#2337)", () => {
).toThrow("invalid_event_ledger");
});
});

describe("prepareOpenPrSubmission (#2337 open-pr call site)", () => {
it("shapes a ready:true openPrInput exactly matching buildOpenPrSpec's expected fields when the gate allows", () => {
const eventLedger = tempEventLedger();

const result = prepareOpenPrSubmission(
{
killSwitchScope: "none",
repoFullName: "acme/widgets",
handoffPacket: handoffPacket({ branchRef: "attempt/1" }),
slopThreshold: "low",
mode: "enforce",
base: "main",
title: "fix: add retry logic",
body: "Closes #1.",
draft: true,
},
{ eventLedger },
);

expect(result.ready).toBe(true);
if (!result.ready) throw new Error("expected ready:true");
expect(result.decision.allow).toBe(true);
expect(result.openPrInput).toEqual({
repoFullName: "acme/widgets",
base: "main",
head: "attempt/1",
title: "fix: add retry logic",
body: "Closes #1.",
draft: true,
});
});

it("returns ready:false (no openPrInput) when the gate blocks, without requiring a branch to open a PR from at all", () => {
const eventLedger = tempEventLedger();

// Kill-switch active AND no branchRef on the handoff -- proves the head-branch check never runs on the
// blocked path (a candidate that will never open a PR must not throw for an unrelated missing-field reason).
const result = prepareOpenPrSubmission(
{
killSwitchScope: "global",
repoFullName: "acme/widgets",
handoffPacket: handoffPacket(),
slopThreshold: "low",
mode: "enforce",
base: "main",
title: "fix: add retry logic",
},
{ eventLedger },
);

expect(result.ready).toBe(false);
expect(result.decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"], circuitBreakerTripped: false });
expect((result as { openPrInput?: unknown }).openPrInput).toBeUndefined();
});

it("throws invalid_pr_base on a missing/blank base, before any gate evaluation or ledger write", () => {
const eventLedger = tempEventLedger();
expect(() =>
prepareOpenPrSubmission(
{ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", title: "t" } as never,
{ eventLedger },
),
).toThrow("invalid_pr_base");
expect(() =>
prepareOpenPrSubmission(
{ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: " ", title: "t" },
{ eventLedger },
),
).toThrow("invalid_pr_base");
expect(eventLedger.readEvents({})).toHaveLength(0); // failed before the wrapped gate call ever wrote an event
});

it("throws invalid_pr_title on a missing/blank title, before any gate evaluation or ledger write", () => {
const eventLedger = tempEventLedger();
expect(() =>
prepareOpenPrSubmission(
{ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: "main" } as never,
{ eventLedger },
),
).toThrow("invalid_pr_title");
expect(eventLedger.readEvents({})).toHaveLength(0);
});

it("throws invalid_pr_head_branch when the gate allows but the handoff packet has no branch to open a PR from -- the allow decision is still recorded to the ledger", () => {
const eventLedger = tempEventLedger();

expect(() =>
prepareOpenPrSubmission(
{
killSwitchScope: "none",
repoFullName: "acme/widgets",
handoffPacket: handoffPacket(), // no branchRef
slopThreshold: "low",
mode: "enforce",
base: "main",
title: "t",
},
{ eventLedger },
),
).toThrow("invalid_pr_head_branch");

// The gate's own allow:true decision is still on the audit trail even though the spec-build step failed.
const events = eventLedger.readEvents({ repoFullName: "acme/widgets" });
expect(events).toHaveLength(1);
expect(events[0]?.payload).toMatchObject({ allow: true });
});

it("defaults draft to false and body to an empty string when omitted", () => {
const eventLedger = tempEventLedger();
const result = prepareOpenPrSubmission(
{ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: "main", title: "t" },
{ eventLedger },
);
expect(result.ready).toBe(true);
if (!result.ready) throw new Error("expected ready:true");
expect(result.openPrInput.draft).toBe(false);
expect(result.openPrInput.body).toBe("");
});

it("trims repoFullName/base/title/head", () => {
const eventLedger = tempEventLedger();
const result = prepareOpenPrSubmission(
{
killSwitchScope: "none",
repoFullName: " acme/widgets ",
handoffPacket: handoffPacket({ branchRef: " attempt/1 " }),
slopThreshold: "low",
mode: "enforce",
base: " main ",
title: " t ",
},
{ eventLedger },
);
expect(result.ready).toBe(true);
if (!result.ready) throw new Error("expected ready:true");
expect(result.openPrInput).toMatchObject({ repoFullName: "acme/widgets", base: "main", head: "attempt/1", title: "t" });
});

it("fails closed on a malformed candidate (null, or a non-object primitive) rather than silently allowing", () => {
const eventLedger = tempEventLedger();
expect(() => prepareOpenPrSubmission(null as never, { eventLedger })).toThrow("invalid_harness_submission_candidate");
expect(() => prepareOpenPrSubmission("nope" as never, { eventLedger })).toThrow("invalid_harness_submission_candidate");
});
});