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
7 changes: 7 additions & 0 deletions packages/loopover-engine/src/miner/iterate-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
// return value. A logging failure never alters the loop's decision (mirrors the governor-ledger and
// pretooluse-hook append-failure handling elsewhere in this package).

import type { AutonomyLevel } from "../types/manifest-deps-types.js";
import type { CodingAgentDriver, CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js";
import { codingAgentModeExecutes, type CodingAgentExecutionMode } from "./coding-agent-mode.js";
import { invokeCodingAgentDriver } from "./coding-agent-invoke.js";
Expand Down Expand Up @@ -86,6 +87,11 @@ export type IterateLoopInput = {
* automated contributions -- resolved by the caller (AI-policy-map / rejection-state-machine), consumed
* as-is. See iterate-policy.ts's own `IterationState.rejectionSignaled` doc comment. */
rejectionSignaled: boolean;

/** The operator's configured self-loop autonomy level (#6560), resolved by the caller from
* `AmsPolicySpec.selfLoopAutonomy` and consumed as-is. Gates the pass->handoff transition only. See
* iterate-policy.ts's own `IterationState.autonomyLevel` doc comment. */
autonomyLevel?: AutonomyLevel | undefined;
};

/** Optional cooperative abort probed BEFORE every driver invocation (#5670). A bare `true` or
Expand Down Expand Up @@ -452,6 +458,7 @@ async function runIterateLoopCore(input: IterateLoopInput, deps: IterateLoopDeps
selfReview,
previousBlockerCodes,
rejectionSignaled: input.rejectionSignaled,
autonomyLevel: input.autonomyLevel,
};
const decision = decideNextActionWithReason(state);
logDecision(input, deps, iterationNumber, decision, []);
Expand Down
45 changes: 39 additions & 6 deletions packages/loopover-engine/src/miner/iterate-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
// ceilings exist so a stuck loop stops wasting turns (or spend) chasing a submission that was never going
// to land, rather than grinding toward *a* submission for its own sake.
//
// AUTONOMY DIAL (not yet wired): `src/settings/autonomy.ts`'s `resolveAutonomy`/`isActingAutonomyLevel` is the
// existing reusable deny-by-default pattern this policy's eventual live autonomy-level check should consult
// once `.loopover-miner.yml` defines autonomy fields -- that wiring is explicitly left to a later phase; this
// module's `decideNextAction` is autonomy-level-agnostic today.
// AUTONOMY DIAL (wired in #6560): `IterationState.autonomyLevel` carries the operator's configured
// `AmsPolicySpec.selfLoopAutonomy` (#6559) and narrows step 3 of the ladder below -- the pass->handoff
// transition -- and nothing else. It never touches the iteration/cost ceilings or steps 1-2, and it is
// optional: an unset level is treated as `"auto"`, so a pre-#6560 `IterationState` decides exactly as before.

import type { AutonomyLevel } from "../types/manifest-deps-types.js";

import type { SelfReviewVerdict } from "./self-review-adapter.js";

Expand All @@ -35,7 +37,10 @@ export type AbandonReason =
| "no_progress"
/** Mid-attempt emergency stop (#5670): kill-switch (or operator pause acting as a stop signal) tripped
* between iterate-loop iterations — cooperative, not a hard SIGKILL of an in-flight driver call. */
| "kill_switch_engaged";
| "kill_switch_engaged"
/** A clean predicted-gate pass WAS reached, but the configured self-loop autonomy level is "observe" (#6560),
* so the loop stops instead of handing off. Distinct from every other abandon: nothing went wrong. */
| "autonomy_observe_only";

/**
* The self-review outcome as the policy needs it -- narrower than the full {@link SelfReviewVerdict} (self-
Expand Down Expand Up @@ -85,6 +90,11 @@ export type IterationState = {
* signals or the rejection-state-machine primitive already shipped in `packages/loopover-miner/lib/`) and
* passes it in; this policy does not compute it itself. */
rejectionSignaled: boolean;
/** The operator's configured self-loop autonomy level (#6560), from `AmsPolicySpec.selfLoopAutonomy`. Gates
* the pass->handoff transition ONLY -- never the iteration or cost ceilings, and never steps 1-2 of the
* precedence ladder. Optional and treated as `"auto"` when undefined, so every `IterationState` fixture that
* predates this field keeps its exact prior decision (same precedent as `costCeilingReached` above). */
autonomyLevel?: AutonomyLevel | undefined;
};

/** Forward-looking INTERFACE for Phase 4 (submission), not an implementation of it -- Phase 4 lands as a later,
Expand Down Expand Up @@ -115,6 +125,10 @@ export type IterateLoopDecision = {
reason: string;
/** Populated only when `action === "abandon"`. */
abandonReason?: AbandonReason | undefined;
/** Populated only when `action === "handoff"` under the `"auto_with_approval"` autonomy level (#6560) --
* the handoff still happens, but the caller must gate it behind an operator approval. Mirrors
* settings/autonomy.ts's `autonomyRequiresApproval`. */
requiresApproval?: true | undefined;
};

function blockerSetsEqual(current: readonly string[], previous: readonly string[]): boolean {
Expand All @@ -132,7 +146,9 @@ function blockerSetsEqual(current: readonly string[], previous: readonly string[
* Precedence (each check short-circuits the ones below it):
* 1. `rejectionSignaled` -- ALWAYS abandons, even over an otherwise-passing self-review (disengage silently).
* 2. `selfReview.kind === "ambiguous"` -- abandons; never optimistically continues or hands off on ambiguity.
* 3. `selfReview.kind === "pass"` -- the ONLY path to `"handoff"`.
* 3. `selfReview.kind === "pass"` -- the ONLY path to `"handoff"`, narrowed by `autonomyLevel` (#6560):
* `"auto"` (or unset) hands off; `"auto_with_approval"` hands off with `requiresApproval: true`;
* `"observe"` abandons with `"autonomy_observe_only"`.
* 4. `iterationNumber >= maxIterations` -- abandons at the hard ceiling regardless of whether the blocker set
* was still changing (genuine incremental progress does not buy unlimited iterations).
* 5. `costCeilingReached` -- abandons at the hard cost ceiling, same rationale as the iteration ceiling above.
Expand All @@ -152,6 +168,23 @@ export function decideNextActionWithReason(state: IterationState): IterateLoopDe
};
}
if (state.selfReview.kind === "pass") {
// #6560: autonomy narrows the ONLY path to handoff. Steps 1-2 above already short-circuited, so an
// "observe" level can never resurrect a rejection-signaled or ambiguous state into a pass.
const autonomyLevel = state.autonomyLevel ?? "auto";
if (autonomyLevel === "observe") {
return {
action: "abandon",
abandonReason: "autonomy_observe_only",
reason: "Self-review reached a clean predicted-gate pass, but the configured self-loop autonomy level is observe-only; stopping without handing off.",
};
}
if (autonomyLevel === "auto_with_approval") {
return {
action: "handoff",
reason: "Self-review reached a clean predicted-gate pass.",
requiresApproval: true,
};
}
return { action: "handoff", reason: "Self-review reached a clean predicted-gate pass." };
}
if (state.iterationNumber >= state.maxIterations) {
Expand Down
42 changes: 42 additions & 0 deletions packages/loopover-engine/test/iterate-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,45 @@ test("deriveSelfReviewOutcome: a failing verdict maps to fail with the real bloc
} as unknown as SelfReviewVerdict;
assert.deepEqual(deriveSelfReviewOutcome(verdict), { kind: "fail", blockerCodes: ["duplicate_pr_risk", "missing_linked_issue"] });
});

// #6560: autonomy narrows the pass->handoff transition ONLY. Mirrored as a vitest suite at
// test/unit/engine-iterate-policy-autonomy.test.ts, which is what codecov/patch actually measures for
// packages/loopover-engine/src/**.
function passingState(overrides: Partial<IterationState> = {}): IterationState {
return baseState({ selfReview: { kind: "pass" }, ...overrides });
}

test("autonomy #6560: \"auto\" hands off with no requiresApproval", () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "auto" }));
assert.equal(decision.action, "handoff");
assert.equal(decision.requiresApproval, undefined);
});

test("autonomy #6560: \"auto_with_approval\" hands off AND flags requiresApproval", () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "auto_with_approval" }));
assert.equal(decision.action, "handoff");
assert.equal(decision.requiresApproval, true);
});

test("autonomy #6560: \"observe\" abandons with autonomy_observe_only despite the clean pass", () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "observe" }));
assert.equal(decision.action, "abandon");
assert.equal(decision.abandonReason, "autonomy_observe_only");
assert.ok(decision.reason.includes("clean predicted-gate pass"));
assert.ok(decision.reason.includes("observe-only"));
});

test("autonomy #6560: an unset autonomyLevel is byte-identical to an explicit \"auto\" (true no-op)", () => {
const omitted = passingState();
assert.equal(omitted.autonomyLevel, undefined);
assert.deepEqual(decideNextActionWithReason(omitted), decideNextActionWithReason(passingState({ autonomyLevel: "auto" })));
});

test("autonomy #6560: rejectionSignaled and an ambiguous self-review still win over \"observe\"", () => {
const rejected = decideNextActionWithReason(passingState({ autonomyLevel: "observe", rejectionSignaled: true }));
assert.equal(rejected.abandonReason, "rejection_signaled");
const ambiguous = decideNextActionWithReason(
baseState({ autonomyLevel: "observe", selfReview: { kind: "ambiguous", reason: "unclear" } }),
);
assert.equal(ambiguous.abandonReason, "self_review_ambiguous");
});
3 changes: 3 additions & 0 deletions packages/loopover-miner/lib/attempt-input-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,8 @@ export function buildAttemptLoopInput(input) {
branchRef: input.branchRef,
reviewContext: input.reviewContext,
rejectionSignaled: input.rejectionSignaled,
// #6560: the operator's configured self-loop autonomy level reaches the policy the same way every other
// AmsPolicySpec knob above does. It gates only the pass->handoff transition inside iterate-policy.js.
autonomyLevel: input.amsPolicySpec.selfLoopAutonomy,
};
}
117 changes: 117 additions & 0 deletions test/unit/engine-iterate-policy-autonomy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";

import {
decideNextActionWithReason,
type IterationState,
} from "../../packages/loopover-engine/src/index";

// Vitest mirror of packages/loopover-engine/test/iterate-policy.test.ts's autonomy cases (#6560). codecov/patch
// is computed from this app vitest run (vitest.config coverage includes packages/loopover-engine/src/**), so the
// changed engine lines need a vitest test that imports the SRC directly — the engine's own node:test suite is
// not collected here.

function baseState(overrides: Partial<IterationState> = {}): IterationState {
return {
iterationNumber: 1,
maxIterations: 5,
selfReview: { kind: "fail", blockerCodes: ["missing_linked_issue"] },
previousBlockerCodes: null,
rejectionSignaled: false,
...overrides,
};
}

/** A state whose only path is step 3 — a clean predicted-gate pass. */
function passingState(overrides: Partial<IterationState> = {}): IterationState {
return baseState({ selfReview: { kind: "pass" }, ...overrides });
}

describe("decideNextActionWithReason autonomy gating (#6560)", () => {
it('"auto" hands off with no requiresApproval', () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "auto" }));
expect(decision.action).toBe("handoff");
expect(decision.requiresApproval).toBeUndefined();
expect(decision.abandonReason).toBeUndefined();
});

it('"auto_with_approval" still hands off, but flags requiresApproval', () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "auto_with_approval" }));
expect(decision.action).toBe("handoff");
expect(decision.requiresApproval).toBe(true);
});

it('"observe" abandons with autonomy_observe_only, noting the pass WAS reached', () => {
const decision = decideNextActionWithReason(passingState({ autonomyLevel: "observe" }));
expect(decision.action).toBe("abandon");
expect(decision.abandonReason).toBe("autonomy_observe_only");
// The reason must say a clean pass was reached and the level is what stopped it — not reuse another
// reason's wording (nothing went wrong here).
expect(decision.reason).toContain("clean predicted-gate pass");
expect(decision.reason).toContain("observe-only");
expect(decision.requiresApproval).toBeUndefined();
});

it("REGRESSION: an unset autonomyLevel is byte-identical to an explicit \"auto\" — the field is a true no-op", () => {
// Mirrors costCeilingReached's own omitted/explicit-default pair: every pre-#6560 IterationState fixture
// must keep its exact prior decision.
const omitted = passingState();
expect(omitted.autonomyLevel).toBeUndefined();
const explicitUndefined = passingState({ autonomyLevel: undefined });
const explicitAuto = passingState({ autonomyLevel: "auto" });

expect(decideNextActionWithReason(omitted)).toEqual(decideNextActionWithReason(explicitAuto));
expect(decideNextActionWithReason(explicitUndefined)).toEqual(decideNextActionWithReason(explicitAuto));
expect(decideNextActionWithReason(omitted)).toEqual({
action: "handoff",
reason: "Self-review reached a clean predicted-gate pass.",
});
});
});

describe("autonomy never overrides the higher-precedence ladder steps (#6560)", () => {
it.each(["auto", "auto_with_approval", "observe"] as const)(
"rejectionSignaled still wins over autonomyLevel=%s",
(autonomyLevel) => {
// Step 1 is absolute: disengage silently on rejection, even over an otherwise-passing self-review.
const decision = decideNextActionWithReason(passingState({ autonomyLevel, rejectionSignaled: true }));
expect(decision.action).toBe("abandon");
expect(decision.abandonReason).toBe("rejection_signaled");
expect(decision.requiresApproval).toBeUndefined();
},
);

it.each(["auto", "auto_with_approval", "observe"] as const)(
"an ambiguous self-review still wins over autonomyLevel=%s",
(autonomyLevel) => {
const decision = decideNextActionWithReason(
baseState({ autonomyLevel, selfReview: { kind: "ambiguous", reason: "unclear" } }),
);
expect(decision.action).toBe("abandon");
expect(decision.abandonReason).toBe("self_review_ambiguous");
},
);

it.each(["auto", "auto_with_approval", "observe"] as const)(
"autonomyLevel=%s does not touch the iteration ceiling / no-progress steps below it",
(autonomyLevel) => {
// A failing self-review never reaches step 3, so autonomy must be irrelevant to steps 4-6.
const ceiling = decideNextActionWithReason(
baseState({ autonomyLevel, iterationNumber: 5, maxIterations: 5 }),
);
expect(ceiling.action).toBe("abandon");
expect(ceiling.abandonReason).toBe("max_iterations_reached");

const noProgress = decideNextActionWithReason(
baseState({
autonomyLevel,
iterationNumber: 2,
maxIterations: 10,
selfReview: { kind: "fail", blockerCodes: ["same_code"] },
previousBlockerCodes: ["same_code"],
}),
);
expect(noProgress.action).toBe("abandon");
expect(noProgress.abandonReason).toBe("no_progress");
},
);
});
33 changes: 33 additions & 0 deletions test/unit/miner-attempt-input-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe("buildAttemptLoopInput (#5132)", () => {
branchRef: undefined,
reviewContext: reviewContext(),
rejectionSignaled: false,
autonomyLevel: DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy,
});
});

Expand Down Expand Up @@ -179,6 +180,38 @@ describe("buildAttemptLoopInput (#5132)", () => {
expect(loopInput.mode).toBe("live");
});

it("#6560: threads amsPolicySpec.selfLoopAutonomy through as IterateLoopInput.autonomyLevel", () => {
for (const selfLoopAutonomy of ["auto", "auto_with_approval", "observe"] as const) {
const loopInput = buildAttemptLoopInput({
codingTaskSpec: codingTaskSpec(),
reviewContext: reviewContext(),
worktreePath: "/fake",
attemptId: "a1",
mode: "live",
repoFullName: "acme/widgets",
minerLogin: "alice",
rejectionSignaled: false,
amsPolicySpec: { ...DEFAULT_AMS_POLICY_SPEC, selfLoopAutonomy },
});
expect(loopInput.autonomyLevel).toBe(selfLoopAutonomy);
}
});

it("#6560: the default policy spec's autonomy level flows through unchanged (no fabricated default)", () => {
const loopInput = buildAttemptLoopInput({
codingTaskSpec: codingTaskSpec(),
reviewContext: reviewContext(),
worktreePath: "/fake",
attemptId: "a1",
mode: "live",
repoFullName: "acme/widgets",
minerLogin: "alice",
rejectionSignaled: false,
amsPolicySpec: DEFAULT_AMS_POLICY_SPEC,
});
expect(loopInput.autonomyLevel).toBe(DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy);
});

it("uses AmsPolicySpec's real maxIterations/maxTurnsPerIteration, not hardcoded literals", () => {
const loopInput = buildAttemptLoopInput({
codingTaskSpec: codingTaskSpec(),
Expand Down