diff --git a/.gittensory-ams.yml.example b/.gittensory-ams.yml.example index 907d4f0aac..9743c1f1e8 100644 --- a/.gittensory-ams.yml.example +++ b/.gittensory-ams.yml.example @@ -58,3 +58,12 @@ capLimits: convergenceThresholds: maxConsecutiveFailures: 3 maxReenqueues: 3 + +# Hard ceiling on the iterate loop's own iteration count for one attempt. +# A non-integer is floored; 0 abandons before the first driver invocation. +# Default: 3. +maxIterations: 3 + +# Per-iteration turn budget passed to the coding-agent driver. +# A non-integer is floored. Default: 6. +maxTurnsPerIteration: 6 diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 7f9fdaa5e7..a3ce36726c 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -646,8 +646,8 @@ the existence check — so a caller reads the returned path and feeds its conten ## AmsPolicySpec `AmsPolicySpec` is the type surface for `.gittensory-ams.yml` — the OPERATOR's own execution-risk policy for -their miner (`submissionMode`, `slopThreshold`, `capLimits`, `convergenceThresholds`), a deliberate structural -sibling to `MinerGoalSpec` but answering a different question: `MinerGoalSpec` is what the target repo wants +their miner (`submissionMode`, `slopThreshold`, `capLimits`, `convergenceThresholds`, `maxIterations`, +`maxTurnsPerIteration`), a deliberate structural sibling to `MinerGoalSpec` but answering a different question: `MinerGoalSpec` is what the target repo wants from being mined; `AmsPolicySpec` is how aggressive the operator wants their own agent to be. No field on this type lets a target repo's own file loosen what an operator's agent is willing to do — see the type's own header comment for why that boundary is load-bearing. `DEFAULT_AMS_POLICY_SPEC` is deny-by-default: `"observe"` diff --git a/packages/gittensory-engine/src/ams-policy-spec.ts b/packages/gittensory-engine/src/ams-policy-spec.ts index 0a32fb0b4d..61b89c1947 100644 --- a/packages/gittensory-engine/src/ams-policy-spec.ts +++ b/packages/gittensory-engine/src/ams-policy-spec.ts @@ -52,6 +52,11 @@ export type AmsPolicySpec = { capLimits: AmsCapLimits; /** Non-convergence detector thresholds. Default: {@link DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS}. */ convergenceThresholds: PortfolioConvergenceThresholds; + /** Hard ceiling on the iterate loop's own iteration count (IterateLoopInput.maxIterations). Default: 3. */ + maxIterations: number; + /** Per-iteration turn budget passed to the coding-agent driver (IterateLoopInput.maxTurnsPerIteration). + * Default: 6. */ + maxTurnsPerIteration: number; }; /** The tolerant parser result for `.gittensory-ams.yml`. Mirrors `ParsedMinerGoalSpec`'s present/warnings shape. */ @@ -70,6 +75,8 @@ export const DEFAULT_AMS_POLICY_SPEC: Readonly = Object.freeze({ slopThreshold: "low", capLimits: Object.freeze({ budget: 5, turns: 20, elapsedMs: 1_800_000 }), convergenceThresholds: Object.freeze({ ...DEFAULT_PORTFOLIO_CONVERGENCE_THRESHOLDS }), + maxIterations: 3, + maxTurnsPerIteration: 6, }); const MAX_AMS_POLICY_SPEC_BYTES = 8_192; @@ -80,6 +87,8 @@ function cloneDefaultAmsPolicySpec(): AmsPolicySpec { slopThreshold: DEFAULT_AMS_POLICY_SPEC.slopThreshold, capLimits: { ...DEFAULT_AMS_POLICY_SPEC.capLimits }, convergenceThresholds: { ...DEFAULT_AMS_POLICY_SPEC.convergenceThresholds }, + maxIterations: DEFAULT_AMS_POLICY_SPEC.maxIterations, + maxTurnsPerIteration: DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration, }; } @@ -110,6 +119,13 @@ function normalizePositiveNumber(value: unknown, field: string, fallback: number return value; } +/** Like normalizePositiveNumber, but floors to a whole count -- for fields that are semantically integer + * counts (an iteration/turn budget), matching MinerGoalSpec's own normalizePositiveInteger convention. */ +function normalizeNonNegativeInteger(value: unknown, field: string, fallback: number, warnings: string[]): number { + const normalized = normalizePositiveNumber(value, field, fallback, warnings); + return Math.floor(normalized); +} + function normalizeCapLimits(value: unknown, fallback: AmsCapLimits, warnings: string[]): AmsCapLimits { if (value === undefined || value === null) return fallback; if (typeof value !== "object" || Array.isArray(value)) { @@ -154,7 +170,9 @@ function hasConfiguredPolicyFields(spec: AmsPolicySpec): boolean { spec.capLimits.turns !== DEFAULT_AMS_POLICY_SPEC.capLimits.turns || spec.capLimits.elapsedMs !== DEFAULT_AMS_POLICY_SPEC.capLimits.elapsedMs || spec.convergenceThresholds.maxConsecutiveFailures !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxConsecutiveFailures || - spec.convergenceThresholds.maxReenqueues !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues + spec.convergenceThresholds.maxReenqueues !== DEFAULT_AMS_POLICY_SPEC.convergenceThresholds.maxReenqueues || + spec.maxIterations !== DEFAULT_AMS_POLICY_SPEC.maxIterations || + spec.maxTurnsPerIteration !== DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration ); } @@ -190,6 +208,13 @@ export function parseAmsPolicySpec(raw: unknown): ParsedAmsPolicySpec { DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, warnings, ), + maxIterations: normalizeNonNegativeInteger(record.maxIterations, "maxIterations", DEFAULT_AMS_POLICY_SPEC.maxIterations, warnings), + maxTurnsPerIteration: normalizeNonNegativeInteger( + record.maxTurnsPerIteration, + "maxTurnsPerIteration", + DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration, + warnings, + ), }; if (!hasConfiguredPolicyFields(spec)) { warnings.push("AmsPolicySpec contained no recognized non-default policy fields; falling back to safe defaults."); diff --git a/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts b/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts index 63589ff677..41c1d690b2 100644 --- a/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts +++ b/packages/gittensory-engine/test/ams-policy-spec-parser.test.ts @@ -38,6 +38,8 @@ test("parseAmsPolicySpec: valid raw config normalizes every field and keeps non- slopThreshold: "clean", capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + maxIterations: 5, + maxTurnsPerIteration: 10, }); assert.equal(parsed.present, true); @@ -46,10 +48,30 @@ test("parseAmsPolicySpec: valid raw config normalizes every field and keeps non- slopThreshold: "clean", capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + maxIterations: 5, + maxTurnsPerIteration: 10, }); assert.deepEqual(parsed.warnings, []); }); +test("parseAmsPolicySpec: maxIterations/maxTurnsPerIteration floor to whole counts and reject negative/non-numeric values", () => { + const floored = parseAmsPolicySpec({ maxIterations: 4.9, maxTurnsPerIteration: 8.2 }); + assert.equal(floored.spec.maxIterations, 4); + assert.equal(floored.spec.maxTurnsPerIteration, 8); + assert.deepEqual(floored.warnings, []); + + const zero = parseAmsPolicySpec({ maxIterations: 0, submissionMode: "enforce" }); + assert.equal(zero.spec.maxIterations, 0); + + const negative = parseAmsPolicySpec({ maxIterations: -1 }); + assert.equal(negative.spec.maxIterations, DEFAULT_AMS_POLICY_SPEC.maxIterations); + assert.match(negative.warnings.join(" "), /maxIterations/i); + + const nonNumeric = parseAmsPolicySpec({ maxTurnsPerIteration: "many" }); + assert.equal(nonNumeric.spec.maxTurnsPerIteration, DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration); + assert.match(nonNumeric.warnings.join(" "), /maxTurnsPerIteration/i); +}); + test("parseAmsPolicySpec: submissionMode rejects an unrecognized value", () => { const parsed = parseAmsPolicySpec({ submissionMode: "yolo" }); assert.equal(parsed.spec.submissionMode, "observe"); diff --git a/packages/gittensory-miner/lib/attempt-cli.d.ts b/packages/gittensory-miner/lib/attempt-cli.d.ts index 922a913be9..955df591b2 100644 --- a/packages/gittensory-miner/lib/attempt-cli.d.ts +++ b/packages/gittensory-miner/lib/attempt-cli.d.ts @@ -1,13 +1,16 @@ import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine"; -import type { AttemptDeps } from "./attempt-runner.js"; +import type { AttemptDeps, runMinerAttempt } from "./attempt-runner.js"; import type { ClaimLedger } from "./claim-ledger.js"; import type { EventLedger } from "./event-ledger.js"; import type { AttemptLog } from "./attempt-log.js"; import type { GovernorLedger } from "./governor-ledger.js"; import type { WorktreeAllocator } from "./worktree-allocator.js"; import type { resolveRejectionSignaled } from "./rejection-signal.js"; -import type { SelfReviewContextFetch } from "./self-review-context.js"; +import type { SelfReviewContextFetch, fetchSelfReviewContext } from "./self-review-context.js"; import type { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; +import type { buildCodingTaskSpec } from "./coding-task-spec.js"; +import type { resolveAmsPolicy } from "./ams-policy.js"; +import type { checkMinerKillSwitch } from "./governor-kill-switch.js"; export type ParsedAttemptArgs = | { error: string } @@ -35,6 +38,11 @@ export type RunAttemptOptions = { fetchImpl?: SelfReviewContextFetch; prepareAttemptWorktree?: typeof prepareAttemptWorktree; cleanupAttemptWorktree?: typeof cleanupAttemptWorktree; + fetchSelfReviewContext?: typeof fetchSelfReviewContext; + buildCodingTaskSpec?: typeof buildCodingTaskSpec; + resolveAmsPolicy?: typeof resolveAmsPolicy; + checkMinerKillSwitch?: typeof checkMinerKillSwitch; + runMinerAttempt?: typeof runMinerAttempt; }; export function runAttempt(args: string[], options?: RunAttemptOptions): Promise; diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index 1362672995..2aaa2a53a7 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -1,16 +1,16 @@ -// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5). Wires bin/gittensory-miner.js's `attempt` -// subcommand to real infrastructure: worktree allocation (worktree-allocator.js's first real, non-test -// caller), the four ledgers (claim/event/attempt-log/governor), the real coding-agent driver (#5131) and -// slop assessor (#5133), the fetchLiveIssueSnapshot/executeLocalWrite built alongside this file, and mode -// resolution. +// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5 -- the final assembly). Wires bin/gittensory-miner.js's +// `attempt` subcommand to real infrastructure end to end: worktree allocation + real git preparation +// (worktree-allocator.js + attempt-worktree.js), the four ledgers (claim/event/attempt-log/governor), the +// real coding-agent driver (#5131) and slop assessor (#5133), a live SelfReviewContext fetch (#5145), a real +// coding-task spec (#5239), the operator's AmsPolicySpec execution policy (#5249), rejectionSignaled (#5241), +// and finally a real runMinerAttempt call -- the first point in this epic where a real coding agent actually +// runs, not just checks-and-reports-blocked. // -// KNOWN, DELIBERATE GAP: runMinerAttempt requires `loopInput.reviewContext: SelfReviewContext` (issue/PR/ -// manifest data at live-gate fidelity, tracked by #5145) AND a full coding-task spec (title/instructions/ -// acceptanceCriteriaPath, derived from the target issue -- no builder for that exists anywhere in this -// package either, a second gap discovered while building this file and noted on #5132). Rather than -// fabricate placeholder data for either -- which would let a self-review pass "look real" while checking -// nothing -- this command builds and verifies every OTHER real dependency, then reports the block clearly -// instead of calling runMinerAttempt with an invalid or fabricated input. +// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list): +// governor.killSwitchRepoPaused only checks the GLOBAL env-var kill switch, not yet a real per-repo +// `.gittensory-miner.yml` pause (the resolver exists, miner-goal-spec.js/#5255, not wired in HERE yet); and +// governor.convergenceInput is an honest first-attempt-shaped literal, not a real per-issue attempt-history +// query (attempt-log.js's schema has no repo+issue index, and reenqueue counts aren't tracked anywhere yet). import { resolveCodingAgentModeFromConfig } from "@jsonbored/gittensory-engine"; import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; @@ -24,6 +24,12 @@ import { initGovernorLedger } from "./governor-ledger.js"; import { openWorktreeAllocator } from "./worktree-allocator.js"; import { resolveRejectionSignaled } from "./rejection-signal.js"; import { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; +import { fetchSelfReviewContext } from "./self-review-context.js"; +import { buildCodingTaskSpec } from "./coding-task-spec.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import { checkMinerKillSwitch } from "./governor-kill-switch.js"; +import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js"; +import { runMinerAttempt } from "./attempt-runner.js"; const ATTEMPT_USAGE = "Usage: gittensory-miner attempt --miner-login [--base ] [--live] [--json]"; @@ -120,11 +126,12 @@ export function buildAttemptDeps(env, ledgers) { } /** - * Run the `attempt` CLI subcommand. Checks resolveRejectionSignaled first (before consuming a worktree - * slot), acquires a concurrency slot (worktree-allocator.js), assembles real AttemptDeps, then prepares a - * REAL git worktree (attempt-worktree.js: clone/fetch + `git worktree add`) -- then, since no SelfReviewContext - * fetcher or coding-task-spec builder exists yet, reports the block instead of calling runMinerAttempt with - * fabricated data, and cleans up the now-unused worktree. See this file's header for why. + * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> + * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real + * SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real + * AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call + * runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`. + * See this file's header for the documented gaps (per-repo kill-switch pause, real convergence history). */ export async function runAttempt(args, options = {}) { const parsed = parseAttemptArgs(args); @@ -205,9 +212,10 @@ export async function runAttempt(args, options = {}) { allocation = allocator.acquire(attemptId, parsed.repoFullName); + let deps; try { const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; - buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`); @@ -254,54 +262,151 @@ export async function runAttempt(args, options = {}) { return 6; } - const reason = "missing_self_review_context_and_task_spec"; - const blockedResult = { - outcome: "blocked_missing_prerequisite", - reason, - trackingIssue: 5145, + // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. + const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; + const reviewContext = await fetchReviewContext(parsed.repoFullName, { + githubToken: env.GITHUB_TOKEN, + contributorLogin: parsed.minerLogin, + linkedIssues: [parsed.issueNumber], + }); + + // The target issue's own real record, when present in the fetched context. When absent (e.g. already + // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found + // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an + // inert shape for a verdict that immediately blocks. + const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { + number: parsed.issueNumber, + title: "", + body: null, + labels: [], + }; + + const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + const codingTaskSpec = buildTaskSpec({ + repoFullName: parsed.repoFullName, + issue: targetIssue, + context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, + claimLedger, + workingDirectory: worktreeResult.worktreePath, + }); + + if (!codingTaskSpec.ready) { + const reason = `infeasible_${codingTaskSpec.verdict}`; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const infeasibleResult = { + outcome: "blocked_infeasible", + reason, + verdict: codingTaskSpec.verdict, + avoidReasons: codingTaskSpec.feasibility.avoidReasons, + raiseReasons: codingTaskSpec.feasibility.raiseReasons, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(infeasibleResult, null, 2)); + } else { + console.error( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, + ); + } + return 4; + } + + const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); + const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + const killSwitchScope = checkKillSwitch({ env }).scope; + + const loopInput = buildAttemptLoopInput({ + codingTaskSpec, + reviewContext, + worktreePath: worktreeResult.worktreePath, + attemptId, + mode, + repoFullName: parsed.repoFullName, + minerLogin: parsed.minerLogin, + rejectionSignaled: false, + amsPolicySpec: amsPolicy.spec, + branchRef: worktreeResult.branchName, + }); + const governor = buildAttemptGovernorContext(env, amsPolicy.spec); + + const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + const result = await runAttemptPipeline( + { + loopInput, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + killSwitchScope, + slopThreshold: amsPolicy.spec.slopThreshold, + submissionMode: amsPolicy.spec.submissionMode, + governor, + }, + deps, + ); + + worktreeResult.attemptOk = result.outcome === "submitted"; + const finalResult = { + outcome: `attempt_${result.outcome}`, repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, minerLogin: parsed.minerLogin, base: parsed.base, mode, attemptId, - worktreePath: worktreeResult.worktreePath, + submissionMode: amsPolicy.spec.submissionMode, + ...("reason" in result ? { reason: result.reason } : {}), + ...("decision" in result ? { decision: result.decision } : {}), + ...("spec" in result ? { spec: result.spec } : {}), }; - // "attempt_aborted" is the closest fit in ATTEMPT_LOG_EVENT_TYPES's fixed vocabulary - // (@jsonbored/gittensory-engine) for "never started because a hard prerequisite is missing". - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, trackingIssue: 5145 }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason, trackingIssue: 5145 }, - }); - if (parsed.json) { - console.log(JSON.stringify(blockedResult, null, 2)); + console.log(JSON.stringify(finalResult, null, 2)); } else { - console.log( - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: no SelfReviewContext fetcher or coding-task-spec builder yet (tracked by #5145). Worktree, ledgers, driver, live-issue fetch, and local-write execution are wired and ready; runMinerAttempt was not invoked.`, - ); + console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); + } + + switch (result.outcome) { + case "submitted": + return 0; + case "abandon": + return 7; + case "stale": + return 8; + case "blocked": + return 9; + case "governed": + return 10; + default: + return 2; } - return 4; } catch (error) { console.error(error instanceof Error ? error.message : String(error)); return 2; } finally { - // No real attempt ever ran in this worktree (every path above stops before invoking runMinerAttempt) -- - // there's nothing to postmortem, so it's always cleaned up (`attemptOk: true`), matching - // cleanupAttemptWorktree's own retention policy for a worktree with no failure to inspect. + // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call + // happens; every earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since + // nothing ran in the worktree to postmortem -- those default to `true` (nothing to retain), matching + // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). if (worktreeResult?.ok) { const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; - await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, true); + await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); } if (allocation && allocator) allocator.release(attemptId); allocator?.close(); diff --git a/packages/gittensory-miner/lib/attempt-input-builder.d.ts b/packages/gittensory-miner/lib/attempt-input-builder.d.ts new file mode 100644 index 0000000000..2c0d5b1d74 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-input-builder.d.ts @@ -0,0 +1,23 @@ +import type { AmsPolicySpec, CodingAgentExecutionMode, IterateLoopInput, SelfReviewContext } from "@jsonbored/gittensory-engine"; +import type { AttemptGovernorContext } from "./attempt-runner.js"; +import type { CodingTaskSpecResult } from "./coding-task-spec.js"; + +export function buildAttemptGovernorContext( + env: Record, + amsPolicySpec: AmsPolicySpec, +): AttemptGovernorContext; + +export type BuildAttemptLoopInputInput = { + codingTaskSpec: Extract; + reviewContext: SelfReviewContext; + worktreePath: string; + attemptId: string; + mode: CodingAgentExecutionMode; + repoFullName: string; + minerLogin: string; + rejectionSignaled: boolean; + amsPolicySpec: AmsPolicySpec; + branchRef?: string; +}; + +export function buildAttemptLoopInput(input: BuildAttemptLoopInputInput): IterateLoopInput; diff --git a/packages/gittensory-miner/lib/attempt-input-builder.js b/packages/gittensory-miner/lib/attempt-input-builder.js new file mode 100644 index 0000000000..f6d6edaa3d --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-input-builder.js @@ -0,0 +1,81 @@ +import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@jsonbored/gittensory-engine"; + +// Pure composers for runMinerAttempt's real input (#5132, Wave 3.5 -- the final assembly). Everything here is +// a plain in/out transform over already-fetched/already-computed real data (coding-task-spec, #5239; +// self-review-context, #5145; worktree preparation, #5237/#5252; AmsPolicySpec, #5249) -- no fetching, no IO, +// same discipline as coding-task-spec.js's own composers. +// +// KNOWN, DOCUMENTED GAPS (not fabricated -- explicitly left as real, narrow follow-ups): +// - governor.killSwitchRepoPaused is omitted (undefined). The real resolver exists (miner-goal-spec.js's +// resolveMinerGoalSpec, #5255) but isn't wired in HERE yet -- this composer only takes `env`, not a +// `repoPath` to read a real .gittensory-miner.yml from. Only the GLOBAL kill switch (env var) is checked +// until that follow-up lands; a per-repo pause silently can't be detected yet (fails open on that one +// axis only, matching checkMinerKillSwitch's own documented fallback for an omitted repoPaused). +// - governor.convergenceInput is a first-attempt-shaped literal ({ attempts: 0, consecutiveFailures: 0, +// reenqueues: 0, reachedDone: false }), not a real per-issue query. attempt-log.js's schema has no +// repo+issue index (attemptId embeds a timestamp, so it's not a stable group key), and reenqueue counts +// aren't tracked ANYWHERE yet (non-convergence.ts's own header says that belongs on the portfolio-queue +// table once it grows attempt-history columns -- a real, separate schema change, not something to fake +// here). This literal is only ever an UNDER-estimate (reads "fresh, no prior failures" even on a real +// Nth attempt), which fails toward LETTING an attempt through, not blocking one -- documented, not silent. +// - governor.reputationHistory/selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted, which +// chokepoint.ts's own design treats as "skip that stage entirely" -- an honest absence, not a fabricated +// "clean" verdict. + +/** + * Assemble the real Governor chokepoint context for one attempt. rateLimitBuckets/rateLimitBackoffAttempts/ + * capUsage are deliberately omitted -- evaluateGovernorChokepointGatePersisted (#5134) auto-loads them from + * the persisted governor-state store when absent. + * + * @param {Record} env + * @param {import("@jsonbored/gittensory-engine").AmsPolicySpec} amsPolicySpec + * @returns {import("./attempt-runner.js").AttemptGovernorContext} + */ +export function buildAttemptGovernorContext(env, amsPolicySpec) { + return { + killSwitchGlobal: isGlobalMinerKillSwitch(env), + killSwitchRepoPaused: undefined, + liveModeGlobalOptIn: isGlobalMinerLiveModeOptIn(env), + capLimits: amsPolicySpec.capLimits, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + }; +} + +/** + * Assemble the real IterateLoopInput for one attempt from every already-computed real dependency. Pure -- + * throws nothing itself (callers are expected to have already validated `codingTaskSpec.ready`). + * + * @param {{ + * codingTaskSpec: Extract, + * reviewContext: import("@jsonbored/gittensory-engine").SelfReviewContext, + * worktreePath: string, + * attemptId: string, + * mode: import("@jsonbored/gittensory-engine").CodingAgentExecutionMode, + * repoFullName: string, + * minerLogin: string, + * rejectionSignaled: boolean, + * amsPolicySpec: import("@jsonbored/gittensory-engine").AmsPolicySpec, + * branchRef?: string, + * }} input + * @returns {import("@jsonbored/gittensory-engine").IterateLoopInput} + */ +export function buildAttemptLoopInput(input) { + return { + attemptId: input.attemptId, + workingDirectory: input.worktreePath, + acceptanceCriteriaPath: input.codingTaskSpec.acceptanceCriteriaPath, + instructions: input.codingTaskSpec.instructions, + mode: input.mode, + maxIterations: input.amsPolicySpec.maxIterations, + maxTurnsPerIteration: input.amsPolicySpec.maxTurnsPerIteration, + repoFullName: input.repoFullName, + contributorLogin: input.minerLogin, + title: input.codingTaskSpec.title, + body: input.codingTaskSpec.body, + labels: input.codingTaskSpec.labels, + linkedIssues: input.codingTaskSpec.linkedIssues, + branchRef: input.branchRef, + reviewContext: input.reviewContext, + rejectionSignaled: input.rejectionSignaled, + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index d47b4cfec8..3af716b07f 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/ams-policy-spec-parser.test.ts b/test/unit/ams-policy-spec-parser.test.ts index 552fed09fd..b6133a2e77 100644 --- a/test/unit/ams-policy-spec-parser.test.ts +++ b/test/unit/ams-policy-spec-parser.test.ts @@ -40,6 +40,8 @@ describe("AmsPolicySpec parser (#5132)", () => { slopThreshold: "clean", capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + maxIterations: 5, + maxTurnsPerIteration: 10, }); expect(parsed.present).toBe(true); expect(parsed.spec).toEqual({ @@ -47,10 +49,31 @@ describe("AmsPolicySpec parser (#5132)", () => { slopThreshold: "clean", capLimits: { budget: 10, turns: 40, elapsedMs: 3_600_000 }, convergenceThresholds: { maxConsecutiveFailures: 5, maxReenqueues: 2 }, + maxIterations: 5, + maxTurnsPerIteration: 10, }); expect(parsed.warnings).toEqual([]); }); + it("maxIterations/maxTurnsPerIteration floor to whole counts, allow zero, and reject negative/non-numeric values", () => { + const floored = parseAmsPolicySpec({ maxIterations: 4.9, maxTurnsPerIteration: 8.2 }); + expect(floored.spec.maxIterations).toBe(4); + expect(floored.spec.maxTurnsPerIteration).toBe(8); + expect(floored.warnings).toEqual([]); + + expect(parseAmsPolicySpec({ maxIterations: 0, submissionMode: "enforce" }).spec.maxIterations).toBe(0); + + const negative = parseAmsPolicySpec({ maxIterations: -1 }); + expect(negative.spec.maxIterations).toBe(DEFAULT_AMS_POLICY_SPEC.maxIterations); + expect(negative.warnings.join(" ")).toMatch(/maxIterations/i); + + const nonNumeric = parseAmsPolicySpec({ maxTurnsPerIteration: "many" }); + expect(nonNumeric.spec.maxTurnsPerIteration).toBe(DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration); + expect(nonNumeric.warnings.join(" ")).toMatch(/maxTurnsPerIteration/i); + + expect(parseAmsPolicySpec({ maxIterations: undefined, submissionMode: "enforce" }).spec.maxIterations).toBe(DEFAULT_AMS_POLICY_SPEC.maxIterations); + }); + it("reports absent-with-a-warning when every field matches the default (no recognized non-default fields)", () => { const parsed = parseAmsPolicySpec({ submissionMode: "observe", slopThreshold: "low" }); expect(parsed.present).toBe(false); diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index 0cc5fc2901..f80943d64c 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -14,6 +14,7 @@ import { closeDefaultGovernorLedger, initGovernorLedger } from "../../packages/g import { closeDefaultWorktreeAllocator, openWorktreeAllocator } from "../../packages/gittensory-miner/lib/worktree-allocator.js"; import { buildAttemptDeps, parseAttemptArgs, runAttempt } from "../../packages/gittensory-miner/lib/attempt-cli.js"; import type { PrepareAttemptWorktreeResult } from "../../packages/gittensory-miner/lib/attempt-worktree.js"; +import { DEFAULT_AMS_POLICY_SPEC, parseFocusManifest } from "../../packages/gittensory-engine/src/index"; const roots: string[] = []; // Only ever holds ledgers a test itself must close -- runAttempt tests inject theirs via DI and runAttempt's @@ -27,6 +28,45 @@ function fakeWorktreeResult(): Extract = {}) { + return { + resolveRejectionSignaled: async () => false, + prepareAttemptWorktree: async () => fakeWorktreeResult(), + cleanupAttemptWorktree: vi.fn().mockResolvedValue({ ok: true, removed: true }), + fetchSelfReviewContext: async () => fakeReviewContext(), + buildCodingTaskSpec: () => fakeCodingTaskSpec(), + resolveAmsPolicy: async () => ({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default" as const, warnings: [] }), + checkMinerKillSwitch: () => ({ scope: "none" as const, active: false }), + ...overrides, + }; +} + function tempLedgers() { const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-cli-")); roots.push(root); @@ -193,18 +233,18 @@ describe("runAttempt (#5132)", () => { expect(openWorktreeAllocatorSpy).not.toHaveBeenCalled(); }); - it("acquires and releases a real worktree slot, wires real deps, then reports the block instead of fabricating a run", async () => { + it("REGRESSION: runs the full real pipeline end to end and reports a real submitted outcome (exit 0)", async () => { const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - // runAttempt closes every ledger/allocator it owns in its own `finally` block (correct for a real CLI - // invocation), so post-invocation state can't be read off these same instances -- spy on the calls - // instead, asserted before close() ever fires. const releaseSpy = vi.spyOn(allocator, "release"); - const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent"); - const appendEventSpy = vi.spyOn(eventLedger, "appendEvent"); const worktreeResult = fakeWorktreeResult(); - const prepareAttemptWorktreeSpy = vi.fn().mockResolvedValue(worktreeResult); const cleanupAttemptWorktreeSpy = vi.fn().mockResolvedValue({ ok: true, removed: true }); + const runMinerAttemptSpy = vi.fn().mockResolvedValue({ + outcome: "submitted", + spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, + execResult: { code: 0 }, + loopResult: { outcome: "handoff" }, + }); const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { env: { MINER_CODING_AGENT_PROVIDER: "noop" }, @@ -215,39 +255,57 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, - resolveRejectionSignaled: async () => false, - prepareAttemptWorktree: prepareAttemptWorktreeSpy, - cleanupAttemptWorktree: cleanupAttemptWorktreeSpy, + ...readyPipelineOptions({ cleanupAttemptWorktree: cleanupAttemptWorktreeSpy, runMinerAttempt: runMinerAttemptSpy }), }); - expect(exitCode).toBe(4); + expect(exitCode).toBe(0); const printed = JSON.parse(String(log.mock.calls[0]?.[0])); expect(printed).toEqual({ - outcome: "blocked_missing_prerequisite", - reason: "missing_self_review_context_and_task_spec", - trackingIssue: 5145, + outcome: "attempt_submitted", repoFullName: "acme/widgets", issueNumber: 7, minerLogin: "alice", base: "main", mode: "dry_run", attemptId: "fixed-attempt-id", - worktreePath: worktreeResult.worktreePath, + submissionMode: "observe", + spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, }); // The worktree slot was acquired for real and then released, not left dangling. expect(releaseSpy).toHaveBeenCalledWith("fixed-attempt-id"); - // A real, persisted record of the block was written to both ledgers -- not just console output. - expect(appendAttemptLogEventSpy).toHaveBeenCalledWith(expect.objectContaining({ eventType: "attempt_aborted", attemptId: "fixed-attempt-id" })); - expect(appendEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "attempt_blocked", repoFullName: "acme/widgets" })); - // A real git worktree was prepared for this attempt -- and cleaned up, since nothing ran in it. - expect(prepareAttemptWorktreeSpy).toHaveBeenCalledWith("acme/widgets", "fixed-attempt-id", { baseBranch: "main", env: { MINER_CODING_AGENT_PROVIDER: "noop" } }); + // A submitted outcome removes the worktree (attemptOk: true) -- nothing left to postmortem. expect(cleanupAttemptWorktreeSpy).toHaveBeenCalledWith(worktreeResult.repoPath, worktreeResult.worktreePath, true); + + // The real IterateLoopInput was assembled from the real coding-task-spec + review context, not fabricated. + expect(runMinerAttemptSpy).toHaveBeenCalledTimes(1); + const [input, deps] = runMinerAttemptSpy.mock.calls[0]!; + expect(input.loopInput).toMatchObject({ + attemptId: "fixed-attempt-id", + workingDirectory: worktreeResult.worktreePath, + acceptanceCriteriaPath: fakeCodingTaskSpec().acceptanceCriteriaPath, + instructions: fakeCodingTaskSpec().instructions, + mode: "dry_run", + repoFullName: "acme/widgets", + contributorLogin: "alice", + title: fakeCodingTaskSpec().title, + rejectionSignaled: false, + }); + expect(input.issueNumber).toBe(7); + expect(input.minerLogin).toBe("alice"); + expect(input.base).toBe("main"); + expect(input.killSwitchScope).toBe("none"); + expect(input.slopThreshold).toBe(DEFAULT_AMS_POLICY_SPEC.slopThreshold); + expect(input.submissionMode).toBe(DEFAULT_AMS_POLICY_SPEC.submissionMode); + expect(input.governor.capLimits).toEqual(DEFAULT_AMS_POLICY_SPEC.capLimits); + expect(deps).toBeDefined(); + expect(typeof deps.driver.run).toBe("function"); }); - it("resolves live mode only when --live is passed", async () => { + it("resolves live mode only when --live is passed, and threads it through to the real loopInput", async () => { const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runMinerAttemptSpy = vi.fn().mockResolvedValue({ outcome: "abandon", loopResult: {} }); const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--live", "--json"], { env: { MINER_CODING_AGENT_PROVIDER: "noop" }, @@ -256,13 +314,12 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, - resolveRejectionSignaled: async () => false, - prepareAttemptWorktree: async () => fakeWorktreeResult(), - cleanupAttemptWorktree: async () => ({ ok: true, removed: true }), + ...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }), }); - expect(exitCode).toBe(4); + expect(exitCode).toBe(7); expect(JSON.parse(String(log.mock.calls[0]?.[0])).mode).toBe("live"); + expect(runMinerAttemptSpy.mock.calls[0]![0].loopInput.mode).toBe("live"); }); it("prints a human-readable message (not JSON) by default", async () => { @@ -276,14 +333,104 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, - resolveRejectionSignaled: async () => false, - prepareAttemptWorktree: async () => fakeWorktreeResult(), - cleanupAttemptWorktree: async () => ({ ok: true, removed: true }), + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: {} }) }), + }); + + expect(exitCode).toBe(7); + expect(String(log.mock.calls[0]?.[0])).toContain("finished with outcome: abandon"); + }); + + it.each([ + ["stale", 8, { outcome: "stale", reason: "expired", loopResult: {} }], + ["blocked", 9, { outcome: "blocked", decision: { allow: false }, loopResult: {} }], + ["governed", 10, { outcome: "governed", decision: { allowed: false }, loopResult: {} }], + ] as const)("reports a real %s outcome with exit code %i", async (_label, expectedExitCode, mockResult) => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ runMinerAttempt: async () => mockResult }), + }); + + expect(exitCode).toBe(expectedExitCode); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.outcome).toBe(`attempt_${mockResult.outcome}`); + }); + + it("REGRESSION: a non-submitted outcome retains the worktree instead of cleaning it up", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const worktreeResult = fakeWorktreeResult(); + const cleanupAttemptWorktreeSpy = vi.fn().mockResolvedValue({ ok: true, removed: false }); + + await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ + cleanupAttemptWorktree: cleanupAttemptWorktreeSpy, + runMinerAttempt: async () => ({ outcome: "governed", decision: { allowed: false }, loopResult: {} }), + }), + }); + + expect(cleanupAttemptWorktreeSpy).toHaveBeenCalledWith(worktreeResult.repoPath, worktreeResult.worktreePath, false); + }); + + it("REGRESSION: blocks with a real feasibility verdict when the coding-task-spec is infeasible, without ever calling runMinerAttempt", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent"); + const runMinerAttemptSpy = vi.fn(); + const cleanupAttemptWorktreeSpy = vi.fn().mockResolvedValue({ ok: true, removed: true }); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "infeasible-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ + buildCodingTaskSpec: () => ({ + ready: false, + verdict: "raise", + feasibility: { verdict: "raise", avoidReasons: [], raiseReasons: ["target_not_found"], summary: "issue not found" }, + }), + runMinerAttempt: runMinerAttemptSpy, + cleanupAttemptWorktree: cleanupAttemptWorktreeSpy, + }), }); expect(exitCode).toBe(4); - expect(String(log.mock.calls[0]?.[0])).toContain("is blocked"); - expect(String(log.mock.calls[0]?.[0])).toContain("#5145"); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "blocked_infeasible", + reason: "infeasible_raise", + verdict: "raise", + avoidReasons: [], + raiseReasons: ["target_not_found"], + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "infeasible-attempt", + }); + expect(runMinerAttemptSpy).not.toHaveBeenCalled(); + expect(appendAttemptLogEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ eventType: "attempt_aborted", attemptId: "infeasible-attempt", reason: "infeasible_raise" }), + ); + // Nothing ran against this worktree -- cleaned up like every other pre-execution block. + expect(cleanupAttemptWorktreeSpy).toHaveBeenCalledWith(expect.any(String), expect.any(String), true); }); it("reports and cleans up when the coding-agent driver is unconfigured, still releasing the worktree slot", async () => { @@ -403,10 +550,7 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, - resolveRejectionSignaled: resolveRejectionSignaledSpy, - fetchImpl, - prepareAttemptWorktree: async () => fakeWorktreeResult(), - cleanupAttemptWorktree: async () => ({ ok: true, removed: true }), + ...readyPipelineOptions({ resolveRejectionSignaled: resolveRejectionSignaledSpy, fetchImpl, runMinerAttempt: async () => ({ outcome: "abandon", loopResult: {} }) }), }); expect(resolveRejectionSignaledSpy).toHaveBeenCalledWith("acme/widgets", { fetchImpl }); @@ -485,11 +629,31 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, - resolveRejectionSignaled: async () => false, - prepareAttemptWorktree: prepareAttemptWorktreeSpy, - cleanupAttemptWorktree: async () => ({ ok: true, removed: true }), + ...readyPipelineOptions({ prepareAttemptWorktree: prepareAttemptWorktreeSpy, runMinerAttempt: async () => ({ outcome: "abandon", loopResult: {} }) }), }); expect(prepareAttemptWorktreeSpy).toHaveBeenCalledWith("acme/widgets", expect.any(String), expect.objectContaining({ baseBranch: "develop" })); }); + + it("fetches SelfReviewContext with the real miner login and target issue number", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchSelfReviewContextSpy = vi.fn().mockResolvedValue(fakeReviewContext()); + + await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop", GITHUB_TOKEN: "ghp_test" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ fetchSelfReviewContext: fetchSelfReviewContextSpy, runMinerAttempt: async () => ({ outcome: "abandon", loopResult: {} }) }), + }); + + expect(fetchSelfReviewContextSpy).toHaveBeenCalledWith("acme/widgets", { + githubToken: "ghp_test", + contributorLogin: "alice", + linkedIssues: [7], + }); + }); }); diff --git a/test/unit/miner-attempt-input-builder.test.ts b/test/unit/miner-attempt-input-builder.test.ts new file mode 100644 index 0000000000..87c8b638d0 --- /dev/null +++ b/test/unit/miner-attempt-input-builder.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { buildAttemptGovernorContext, buildAttemptLoopInput } from "../../packages/gittensory-miner/lib/attempt-input-builder.js"; +import { DEFAULT_AMS_POLICY_SPEC, parseFocusManifest } from "../../packages/gittensory-engine/src/index"; + +function codingTaskSpec(overrides: Record = {}) { + return { + ready: true as const, + verdict: "go" as const, + feasibility: { verdict: "go" as const, avoidReasons: [], raiseReasons: [], summary: "ready" }, + acceptanceCriteriaPath: "/fake/repo/.gittensory-worktrees/fake/acceptance-criteria.json", + instructions: "Resolve issue #7", + title: "Uploads should retry on 5xx", + body: "Uploads fail silently.", + labels: ["bug"], + linkedIssues: [7], + ...overrides, + }; +} + +function reviewContext() { + return { + manifest: parseFocusManifest(undefined), + repo: { fullName: "acme/widgets", owner: "acme", name: "widgets", isInstalled: true, isRegistered: true, isPrivate: false, htmlUrl: "https://github.com/acme/widgets", defaultBranch: "main" }, + issues: [], + pullRequests: [], + }; +} + +describe("buildAttemptGovernorContext (#5132)", () => { + it("reflects the global kill switch and live-mode env vars, uses AmsPolicySpec's real capLimits", () => { + const ctx = buildAttemptGovernorContext( + { GITTENSORY_MINER_KILL_SWITCH: "1", GITTENSORY_MINER_LIVE_MODE: "live" }, + { ...DEFAULT_AMS_POLICY_SPEC, capLimits: { budget: 9, turns: 8, elapsedMs: 7 } }, + ); + expect(ctx.killSwitchGlobal).toBe(true); + expect(ctx.liveModeGlobalOptIn).toBe(true); + expect(ctx.capLimits).toEqual({ budget: 9, turns: 8, elapsedMs: 7 }); + }); + + it("defaults to false/off when neither env var is set", () => { + const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC); + expect(ctx.killSwitchGlobal).toBe(false); + expect(ctx.liveModeGlobalOptIn).toBe(false); + }); + + it("REGRESSION: killSwitchRepoPaused is omitted (documented gap, not fabricated as false)", () => { + const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC); + expect(ctx.killSwitchRepoPaused).toBeUndefined(); + }); + + it("REGRESSION: convergenceInput is an honest first-attempt-shaped literal, not fabricated real history", () => { + const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC); + expect(ctx.convergenceInput).toEqual({ attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }); + }); + + it("omits rateLimitBuckets/rateLimitBackoffAttempts/capUsage so the persisted governor-state store auto-supplies them", () => { + const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC); + expect(ctx).not.toHaveProperty("rateLimitBuckets"); + expect(ctx).not.toHaveProperty("rateLimitBackoffAttempts"); + expect(ctx).not.toHaveProperty("capUsage"); + }); +}); + +describe("buildAttemptLoopInput (#5132)", () => { + it("assembles a real IterateLoopInput from every already-computed dependency", () => { + const loopInput = buildAttemptLoopInput({ + codingTaskSpec: codingTaskSpec(), + reviewContext: reviewContext(), + worktreePath: "/fake/repo/.gittensory-worktrees/fake", + attemptId: "acme_widgets-7-12345", + mode: "dry_run", + repoFullName: "acme/widgets", + minerLogin: "alice", + rejectionSignaled: false, + amsPolicySpec: DEFAULT_AMS_POLICY_SPEC, + }); + + expect(loopInput).toEqual({ + attemptId: "acme_widgets-7-12345", + workingDirectory: "/fake/repo/.gittensory-worktrees/fake", + acceptanceCriteriaPath: "/fake/repo/.gittensory-worktrees/fake/acceptance-criteria.json", + instructions: "Resolve issue #7", + mode: "dry_run", + maxIterations: DEFAULT_AMS_POLICY_SPEC.maxIterations, + maxTurnsPerIteration: DEFAULT_AMS_POLICY_SPEC.maxTurnsPerIteration, + repoFullName: "acme/widgets", + contributorLogin: "alice", + title: "Uploads should retry on 5xx", + body: "Uploads fail silently.", + labels: ["bug"], + linkedIssues: [7], + branchRef: undefined, + reviewContext: reviewContext(), + rejectionSignaled: false, + }); + }); + + it("threads a real rejectionSignaled:true through unchanged", () => { + const loopInput = buildAttemptLoopInput({ + codingTaskSpec: codingTaskSpec(), + reviewContext: reviewContext(), + worktreePath: "/fake", + attemptId: "a1", + mode: "live", + repoFullName: "acme/widgets", + minerLogin: "alice", + rejectionSignaled: true, + amsPolicySpec: DEFAULT_AMS_POLICY_SPEC, + }); + expect(loopInput.rejectionSignaled).toBe(true); + expect(loopInput.mode).toBe("live"); + }); + + it("uses AmsPolicySpec's real maxIterations/maxTurnsPerIteration, not hardcoded literals", () => { + const loopInput = buildAttemptLoopInput({ + codingTaskSpec: codingTaskSpec(), + reviewContext: reviewContext(), + worktreePath: "/fake", + attemptId: "a1", + mode: "dry_run", + repoFullName: "acme/widgets", + minerLogin: "alice", + rejectionSignaled: false, + amsPolicySpec: { ...DEFAULT_AMS_POLICY_SPEC, maxIterations: 9, maxTurnsPerIteration: 4 }, + }); + expect(loopInput.maxIterations).toBe(9); + expect(loopInput.maxTurnsPerIteration).toBe(4); + }); + + it("passes an explicit branchRef through when provided", () => { + const loopInput = buildAttemptLoopInput({ + codingTaskSpec: codingTaskSpec(), + reviewContext: reviewContext(), + worktreePath: "/fake", + attemptId: "a1", + mode: "dry_run", + repoFullName: "acme/widgets", + minerLogin: "alice", + rejectionSignaled: false, + amsPolicySpec: DEFAULT_AMS_POLICY_SPEC, + branchRef: "gittensory/attempt/a1", + }); + expect(loopInput.branchRef).toBe("gittensory/attempt/a1"); + }); + + it("omits body/labels/linkedIssues when the coding-task-spec itself omits them", () => { + const loopInput = buildAttemptLoopInput({ + codingTaskSpec: codingTaskSpec({ body: undefined, labels: undefined, linkedIssues: [] }), + reviewContext: reviewContext(), + worktreePath: "/fake", + attemptId: "a1", + mode: "dry_run", + repoFullName: "acme/widgets", + minerLogin: "alice", + rejectionSignaled: false, + amsPolicySpec: DEFAULT_AMS_POLICY_SPEC, + }); + expect(loopInput.body).toBeUndefined(); + expect(loopInput.labels).toBeUndefined(); + expect(loopInput.linkedIssues).toEqual([]); + }); +});