diff --git a/packages/loopover-engine/src/miner/iterate-loop.ts b/packages/loopover-engine/src/miner/iterate-loop.ts index 460d06a951..0b69a8349b 100644 --- a/packages/loopover-engine/src/miner/iterate-loop.ts +++ b/packages/loopover-engine/src/miner/iterate-loop.ts @@ -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"; @@ -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 @@ -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, []); diff --git a/packages/loopover-engine/src/miner/iterate-policy.ts b/packages/loopover-engine/src/miner/iterate-policy.ts index e1aff55917..8c7aa3f665 100644 --- a/packages/loopover-engine/src/miner/iterate-policy.ts +++ b/packages/loopover-engine/src/miner/iterate-policy.ts @@ -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"; @@ -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- @@ -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, @@ -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 { @@ -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. @@ -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) { diff --git a/packages/loopover-engine/test/iterate-policy.test.ts b/packages/loopover-engine/test/iterate-policy.test.ts index dbd7f550cb..401135f8d9 100644 --- a/packages/loopover-engine/test/iterate-policy.test.ts +++ b/packages/loopover-engine/test/iterate-policy.test.ts @@ -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 { + 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"); +}); diff --git a/packages/loopover-miner/lib/attempt-input-builder.js b/packages/loopover-miner/lib/attempt-input-builder.js index ea93419f10..787aa5f36d 100644 --- a/packages/loopover-miner/lib/attempt-input-builder.js +++ b/packages/loopover-miner/lib/attempt-input-builder.js @@ -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, }; } diff --git a/test/unit/engine-iterate-policy-autonomy.test.ts b/test/unit/engine-iterate-policy-autonomy.test.ts new file mode 100644 index 0000000000..a24f24f472 --- /dev/null +++ b/test/unit/engine-iterate-policy-autonomy.test.ts @@ -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 { + 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 { + 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"); + }, + ); +}); diff --git a/test/unit/miner-attempt-input-builder.test.ts b/test/unit/miner-attempt-input-builder.test.ts index 2c906a23a2..6840874b58 100644 --- a/test/unit/miner-attempt-input-builder.test.ts +++ b/test/unit/miner-attempt-input-builder.test.ts @@ -145,6 +145,7 @@ describe("buildAttemptLoopInput (#5132)", () => { branchRef: undefined, reviewContext: reviewContext(), rejectionSignaled: false, + autonomyLevel: DEFAULT_AMS_POLICY_SPEC.selfLoopAutonomy, }); }); @@ -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(),