From 95bffe56d37da9fcf3b7f5db78d67e3f6ad29571 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Wed, 8 Jul 2026 18:12:12 -0700 Subject: [PATCH 1/4] feat(miner-hands): add coding-agent dry-run mode and driver seam (#4313) --- packages/gittensory-engine/src/index.ts | 39 ++++++ .../src/miner/attempt-log.ts | 121 ++++++++++++++++++ .../src/miner/coding-agent-driver.ts | 68 ++++++++++ .../src/miner/coding-agent-invoke.ts | 100 +++++++++++++++ .../src/miner/coding-agent-mode.ts | 58 +++++++++ .../src/miner/driver-factory.ts | 111 ++++++++++++++++ .../gittensory-engine/src/plan-templates.ts | 17 ++- .../test/attempt-log.test.ts | 65 ++++++++++ .../test/coding-agent-driver.test.ts | 30 +++++ .../test/coding-agent-invoke.test.ts | 63 +++++++++ .../test/coding-agent-mode.test.ts | 64 +++++++++ .../test/driver-factory.test.ts | 60 +++++++++ src/mcp/server.ts | 1 + test/unit/plan-templates.test.ts | 8 ++ 14 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-engine/src/miner/attempt-log.ts create mode 100644 packages/gittensory-engine/src/miner/coding-agent-driver.ts create mode 100644 packages/gittensory-engine/src/miner/coding-agent-invoke.ts create mode 100644 packages/gittensory-engine/src/miner/coding-agent-mode.ts create mode 100644 packages/gittensory-engine/src/miner/driver-factory.ts create mode 100644 packages/gittensory-engine/test/attempt-log.test.ts create mode 100644 packages/gittensory-engine/test/coding-agent-driver.test.ts create mode 100644 packages/gittensory-engine/test/coding-agent-invoke.test.ts create mode 100644 packages/gittensory-engine/test/coding-agent-mode.test.ts create mode 100644 packages/gittensory-engine/test/driver-factory.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 3e46336637..35cf5491ba 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -136,6 +136,45 @@ export { type GovernorLedgerEventType, type NormalizedGovernorLedgerEvent, } from "./governor-ledger.js"; +export { + ATTEMPT_LOG_EVENT_TYPES, + createAttemptLogBuffer, + formatAttemptLogJsonl, + normalizeAttemptLogEvent, + type AttemptLogEvent, + type AttemptLogEventType, + type NormalizedAttemptLogEvent, +} from "./miner/attempt-log.js"; +export { + codingAgentModeExecutes, + isGlobalMinerCodingAgentPause, + resolveCodingAgentExecutionMode, + resolveCodingAgentModeFromConfig, + type CodingAgentExecutionMode, +} from "./miner/coding-agent-mode.js"; +export { + createFakeCodingAgentDriver, + createNoopCodingAgentDriver, + type CodingAgentDriver, + type CodingAgentDriverResult, + type CodingAgentDriverTask, +} from "./miner/coding-agent-driver.js"; +export { + invokeCodingAgentDriver, + type AttemptLogSink, +} from "./miner/coding-agent-invoke.js"; +export { + CODING_AGENT_DRIVER_CONFIG_ENV, + CODING_AGENT_DRIVER_NAMES, + createCodingAgentDriver, + createFakeCodingAgentDriverForFactory, + isConfiguredCodingAgentDriver, + resolveConfiguredCodingAgentDriverNames, + runCodingAgentAttempt, + type CodingAgentDriverName, + type CreateCodingAgentDriverOptions, + type RunCodingAgentAttemptOptions, +} from "./miner/driver-factory.js"; export * from "./plan-export.js"; export { countPlanStepsByStatus } from "./plan-step-stats.js"; export { countPlanSteps } from "./plan-step-count.js"; diff --git a/packages/gittensory-engine/src/miner/attempt-log.ts b/packages/gittensory-engine/src/miner/attempt-log.ts new file mode 100644 index 0000000000..943fc409ff --- /dev/null +++ b/packages/gittensory-engine/src/miner/attempt-log.ts @@ -0,0 +1,121 @@ +// Driver-level structured attempt log — pure event shapes (#4294). Mirrors `governor-ledger.ts`: fixed vocabulary, +// fail-closed normalization, JSON-round-trip-verified payloads. Persistence (SQLite/JSONL export) is a miner-package +// follow-up; this module is the engine-side contract the invoke layer writes into. + +import type { CodingAgentExecutionMode } from "./coding-agent-mode.js"; + +export const ATTEMPT_LOG_EVENT_TYPES = Object.freeze([ + "attempt_started", + "attempt_shadow", + "attempt_succeeded", + "attempt_failed", + "attempt_aborted", +] as const); + +export type AttemptLogEventType = (typeof ATTEMPT_LOG_EVENT_TYPES)[number]; + +export type AttemptLogEvent = { + eventType: AttemptLogEventType; + attemptId: string; + actionClass: string; + mode: CodingAgentExecutionMode; + reason: string; + payload?: Record | undefined; +}; + +export type NormalizedAttemptLogEvent = { + eventType: AttemptLogEventType; + attemptId: string; + actionClass: string; + mode: CodingAgentExecutionMode; + reason: string; + payloadJson: string; +}; + +const attemptEventTypeSet = new Set(ATTEMPT_LOG_EVENT_TYPES); +const codingAgentModes = new Set(["paused", "dry_run", "live"]); + +function normalizeRequiredString(value: unknown, code: string): string { + if (typeof value !== "string") throw new Error(code); + const trimmed = value.trim(); + if (!trimmed) throw new Error(code); + return trimmed; +} + +function jsonRoundTripEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== "object") return false; + const aIsArray = Array.isArray(a); + if (aIsArray !== Array.isArray(b)) return false; + if (aIsArray) { + const bArr = b as unknown[]; + const aArr = a as unknown[]; + return aArr.length === bArr.length && aArr.every((value, index) => jsonRoundTripEqual(value, bArr[index])); + } + const aKeys = Object.keys(a as object); + const bRecord = b as Record; + return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => Object.hasOwn(bRecord, key) && jsonRoundTripEqual((a as Record)[key], bRecord[key])); +} + +function serializePayload(payload: unknown): string { + if (payload === undefined) return "{}"; + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("invalid_payload"); + } + let json: string; + try { + json = JSON.stringify(payload); + } catch { + throw new Error("invalid_payload"); + } + if (!jsonRoundTripEqual(JSON.parse(json), payload)) { + throw new Error("invalid_payload"); + } + return json; +} + +function normalizeMode(value: unknown): CodingAgentExecutionMode { + const mode = normalizeRequiredString(value, "invalid_mode"); + if (!codingAgentModes.has(mode)) throw new Error("invalid_mode"); + return mode as CodingAgentExecutionMode; +} + +/** Validate and normalize an attempt-log row before append. Fail-closed on unknown types/modes. */ +export function normalizeAttemptLogEvent(input: unknown): NormalizedAttemptLogEvent { + if (!input || typeof input !== "object") throw new Error("invalid_event"); + const event = input as Partial; + const eventType = normalizeRequiredString(event.eventType, "invalid_event_type"); + if (!attemptEventTypeSet.has(eventType)) throw new Error("invalid_event_type"); + return { + eventType: eventType as AttemptLogEventType, + attemptId: normalizeRequiredString(event.attemptId, "invalid_attempt_id"), + actionClass: normalizeRequiredString(event.actionClass, "invalid_action_class"), + mode: normalizeMode(event.mode), + reason: normalizeRequiredString(event.reason, "invalid_reason"), + payloadJson: serializePayload(event.payload), + }; +} + +/** Serialize normalized events as JSONL (one attempt's trace). Pure. */ +export function formatAttemptLogJsonl(events: readonly NormalizedAttemptLogEvent[]): string { + return events.map((event) => JSON.stringify(event)).join("\n"); +} + +/** In-memory appender for tests and local tooling — production persistence imports the normalizer only. */ +export function createAttemptLogBuffer(): { + append: (event: AttemptLogEvent) => NormalizedAttemptLogEvent; + events: () => readonly NormalizedAttemptLogEvent[]; + jsonl: () => string; +} { + const rows: NormalizedAttemptLogEvent[] = []; + return { + append(event) { + const normalized = normalizeAttemptLogEvent(event); + rows.push(normalized); + return normalized; + }, + events: () => rows, + jsonl: () => formatAttemptLogJsonl(rows), + }; +} diff --git a/packages/gittensory-engine/src/miner/coding-agent-driver.ts b/packages/gittensory-engine/src/miner/coding-agent-driver.ts new file mode 100644 index 0000000000..3b6151662f --- /dev/null +++ b/packages/gittensory-engine/src/miner/coding-agent-driver.ts @@ -0,0 +1,68 @@ +// `CodingAgentDriver` interface seam (#4262). Mirrors `SelfHostAi` (`src/selfhost/ai.ts:60-63`): a single `run()` +// method, provider-agnostic task/result types, and injectable deps on concrete implementations (spawn fn, clock, +// filesystem) rather than hardcoded globals. Implementations MAY perform real IO; this file defines only the +// contract — orchestration (mode gating, attempt logging, factory selection) lives in sibling miner modules. + +/** Scoped local task handed to every driver implementation — no GitHub writes, no autonomous continue/stop. */ +export type CodingAgentDriverTask = { + attemptId: string; + workingDirectory: string; + acceptanceCriteriaPath: string; + instructions: string; + maxTurns: number; +}; + +/** Provider-agnostic result — nothing here assumes a subprocess CLI vs. an Agent SDK `query()` loop. */ +export type CodingAgentDriverResult = { + ok: boolean; + changedFiles: readonly string[]; + summary: string; + /** Opaque provider transcript for operator inspection; absent when the driver did not run. */ + transcript?: string | undefined; + turnsUsed?: number | undefined; + error?: string | undefined; +}; + +export interface CodingAgentDriver { + run(task: CodingAgentDriverTask): Promise; +} + +/** Minimal in-memory fake for contract/parity tests — records the last task without IO. */ +export function createFakeCodingAgentDriver( + impl: Partial<{ + run: CodingAgentDriver["run"]; + lastTask: CodingAgentDriverTask | null; + }> = {}, +): CodingAgentDriver & { lastTask: CodingAgentDriverTask | null } { + const state = { lastTask: impl.lastTask ?? null }; + return { + get lastTask() { + return state.lastTask; + }, + run: + impl.run ?? + (async (task) => { + state.lastTask = task; + return { + ok: true, + changedFiles: [], + summary: `fake driver ran ${task.attemptId}`, + turnsUsed: 0, + }; + }), + }; +} + +/** Default-OFF stub driver for factory resolution tests — never touches the filesystem. */ +export function createNoopCodingAgentDriver(): CodingAgentDriver { + return { + async run(task) { + return { + ok: true, + changedFiles: [], + summary: `noop driver acknowledged ${task.attemptId}`, + turnsUsed: 0, + }; + }, + }; +} diff --git a/packages/gittensory-engine/src/miner/coding-agent-invoke.ts b/packages/gittensory-engine/src/miner/coding-agent-invoke.ts new file mode 100644 index 0000000000..43a83904f1 --- /dev/null +++ b/packages/gittensory-engine/src/miner/coding-agent-invoke.ts @@ -0,0 +1,100 @@ +// Mode-gated `CodingAgentDriver` invocation (#4313). Single call site that applies `CodingAgentExecutionMode`, +// writes attempt-log events (#4294), and never spawns the underlying agent unless mode is `live`. + +import type { CodingAgentDriver, CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js"; +import { + codingAgentModeExecutes, + type CodingAgentExecutionMode, +} from "./coding-agent-mode.js"; +import type { AttemptLogEvent } from "./attempt-log.js"; + +export type AttemptLogSink = { + append(event: AttemptLogEvent): void; +}; + +function shadowSummary(task: CodingAgentDriverTask): string { + return `dry-run: would invoke coding agent in ${task.workingDirectory} (≤${task.maxTurns} turns, criteria ${task.acceptanceCriteriaPath})`; +} + +/** + * Invoke a driver under the resolved execution mode. `paused` and `dry_run` never call `driver.run()` — see + * `coding-agent-mode.ts` for the dry-run tradeoff documentation. + */ +export async function invokeCodingAgentDriver( + driver: CodingAgentDriver, + mode: CodingAgentExecutionMode, + task: CodingAgentDriverTask, + log?: AttemptLogSink | undefined, +): Promise { + const base = { attemptId: task.attemptId, actionClass: "codegen", mode } as const; + + if (mode === "paused") { + log?.append({ + eventType: "attempt_aborted", + ...base, + reason: "coding_agent_paused", + payload: { workingDirectory: task.workingDirectory }, + }); + return { + ok: false, + changedFiles: [], + summary: "coding agent paused", + error: "coding_agent_paused", + }; + } + + if (!codingAgentModeExecutes(mode)) { + log?.append({ + eventType: "attempt_shadow", + ...base, + reason: "dry-run: would invoke coding agent without spawning underlying session", + payload: { + workingDirectory: task.workingDirectory, + acceptanceCriteriaPath: task.acceptanceCriteriaPath, + maxTurns: task.maxTurns, + }, + }); + return { + ok: true, + changedFiles: [], + summary: shadowSummary(task), + turnsUsed: 0, + }; + } + + log?.append({ + eventType: "attempt_started", + ...base, + reason: "live coding-agent invocation", + payload: { workingDirectory: task.workingDirectory, maxTurns: task.maxTurns }, + }); + + try { + const result = await driver.run(task); + log?.append({ + eventType: result.ok ? "attempt_succeeded" : "attempt_failed", + ...base, + reason: result.summary, + payload: { + changedFiles: [...result.changedFiles], + turnsUsed: result.turnsUsed ?? null, + error: result.error ?? null, + }, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + log?.append({ + eventType: "attempt_failed", + ...base, + reason: message, + payload: { thrown: true }, + }); + return { + ok: false, + changedFiles: [], + summary: "coding agent invocation failed", + error: message, + }; + } +} diff --git a/packages/gittensory-engine/src/miner/coding-agent-mode.ts b/packages/gittensory-engine/src/miner/coding-agent-mode.ts new file mode 100644 index 0000000000..dbea452246 --- /dev/null +++ b/packages/gittensory-engine/src/miner/coding-agent-mode.ts @@ -0,0 +1,58 @@ +// Coding-agent execution mode (#4313). Mirrors `AgentActionMode` at `src/settings/agent-execution.ts:23-48`: +// three states (`paused` | `dry_run` | `live`), deny-toward-safety precedence (global/per-repo pause beats +// dry-run beats live), and a single `codingAgentModeExecutes` boolean for callers. +// +// Why three states instead of a boolean? The maintainer-action layer already proved the shape: `paused` is an +// operator kill-switch (attempt never starts), `dry_run` is an observe/shadow path (record intent without the +// expensive/dangerous work), and `live` is the only mode that actually spawns/queries the underlying agent. +// A boolean would collapse `paused` into `dry_run`, losing the distinction between "halt entirely" and +// "shadow what would happen". +// +// Dry-run semantics for a CODING agent (#4313): at the driver invocation boundary, `dry_run` is a **pure no-op** +// — the underlying CLI/SDK session is never spawned. Tradeoff documented here: +// • Chosen: never spawn (cheapest, safest, mirrors `agentActionModeExecutes` skipping GitHub mutations). +// • Deferred alternative: run inside an isolated worktree (#4269) but suppress commit/push/PR downstream so +// file edits remain inspectable — requires the worktree primitive and orchestrator gating on create-phase +// steps; the attempt log records `attempt_shadow` with mode=`dry_run` so either path stays auditable. + +/** Whether a coding-agent attempt actually spawns/queries the underlying session. */ +export type CodingAgentExecutionMode = "paused" | "dry_run" | "live"; + +/** Global kill-switch for miner coding-agent invocations (`MINER_CODING_AGENT_PAUSED`). Same truthy-string + * convention as `isGlobalAgentPause` (`AGENT_ACTIONS_PAUSED`). */ +export function isGlobalMinerCodingAgentPause(env: { + MINER_CODING_AGENT_PAUSED?: string | undefined; +}): boolean { + return /^(1|true|yes|on)$/i.test(env.MINER_CODING_AGENT_PAUSED ?? ""); +} + +/** THE single gate before invoking a `CodingAgentDriver`. Precedence (safest wins): global OR per-config pause + * → `paused`; else per-config dry-run → `dry_run`; else `live`. Pure. */ +export function resolveCodingAgentExecutionMode(input: { + globalPaused: boolean; + agentPaused?: boolean | null | undefined; + agentDryRun?: boolean | null | undefined; +}): CodingAgentExecutionMode { + if (input.globalPaused || input.agentPaused === true) return "paused"; + if (input.agentDryRun === true) return "dry_run"; + return "live"; +} + +/** Resolve mode from env + optional per-run overrides (mirrors `resolveAgentActionMode` call sites). */ +export function resolveCodingAgentModeFromConfig(config: { + env?: { MINER_CODING_AGENT_PAUSED?: string | undefined } | undefined; + agentPaused?: boolean | null | undefined; + agentDryRun?: boolean | null | undefined; +}): CodingAgentExecutionMode { + return resolveCodingAgentExecutionMode({ + globalPaused: isGlobalMinerCodingAgentPause(config.env ?? {}), + agentPaused: config.agentPaused, + agentDryRun: config.agentDryRun, + }); +} + +/** True only for `live` — the only mode that performs a real driver `run()`. `paused` does nothing; `dry_run` + * records a shadow result without spawning the underlying agent. */ +export function codingAgentModeExecutes(mode: CodingAgentExecutionMode): boolean { + return mode === "live"; +} diff --git a/packages/gittensory-engine/src/miner/driver-factory.ts b/packages/gittensory-engine/src/miner/driver-factory.ts new file mode 100644 index 0000000000..10e3eaffbc --- /dev/null +++ b/packages/gittensory-engine/src/miner/driver-factory.ts @@ -0,0 +1,111 @@ +// CodingAgentDriver factory + provider-style config resolution (#4289). Mirrors `src/selfhost/ai-config.ts:41-74`: +// parse a comma-separated provider list, validate each name against what is actually configured, deny-by-default +// on unknown/unconfigured names, and expose a model/effort config map analogous to `SELF_HOST_REVIEWER_MODEL_ENV`. + +import { + createFakeCodingAgentDriver, + createNoopCodingAgentDriver, + type CodingAgentDriver, +} from "./coding-agent-driver.js"; +import { + invokeCodingAgentDriver, + type AttemptLogSink, +} from "./coding-agent-invoke.js"; +import { + resolveCodingAgentModeFromConfig, + type CodingAgentExecutionMode, +} from "./coding-agent-mode.js"; +import type { CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js"; + +/** Provider names the factory knows how to resolve today. Concrete CLI/SDK drivers land in #4266/#4267. */ +export const CODING_AGENT_DRIVER_NAMES = Object.freeze(["noop"] as const); + +export type CodingAgentDriverName = (typeof CODING_AGENT_DRIVER_NAMES)[number]; + +/** Per-provider env keys for coding-agent configuration (mirrors `SELF_HOST_REVIEWER_MODEL_ENV`). */ +export const CODING_AGENT_DRIVER_CONFIG_ENV: Readonly> = + Object.freeze({ + noop: {}, + }); + +function parseDriverNames(env: Record): string[] { + return (env.MINER_CODING_AGENT_PROVIDER ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +} + +/** True when `name` is a known, configured coding-agent driver. Unknown names → false (deny-by-default). */ +export function isConfiguredCodingAgentDriver( + name: string, + _env: Record, +): boolean { + switch (name) { + case "noop": + return true; + default: + return false; + } +} + +export function resolveConfiguredCodingAgentDriverNames( + env: Record, +): string[] { + return parseDriverNames(env).filter((name) => isConfiguredCodingAgentDriver(name, env)); +} + +export type CreateCodingAgentDriverOptions = { + providerName: string; + env?: Record | undefined; + /** Test seam — inject a fake driver instead of constructing the named provider. */ + driver?: CodingAgentDriver | undefined; +}; + +/** Resolve a concrete driver for `providerName`. Throws on unknown/unconfigured providers (fail-closed). */ +export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions): CodingAgentDriver { + if (options.driver) return options.driver; + const name = options.providerName.trim().toLowerCase(); + const env = options.env ?? {}; + if (!isConfiguredCodingAgentDriver(name, env)) { + throw new Error(`unconfigured_coding_agent_driver:${name}`); + } + switch (name) { + case "noop": + return createNoopCodingAgentDriver(); + default: + throw new Error(`unconfigured_coding_agent_driver:${name}`); + } +} + +export type RunCodingAgentAttemptOptions = { + providerName: string; + env?: Record | undefined; + agentPaused?: boolean | null | undefined; + agentDryRun?: boolean | null | undefined; + task: CodingAgentDriverTask; + log?: AttemptLogSink | undefined; + driver?: CodingAgentDriver | undefined; +}; + +/** End-to-end entry: resolve mode from config, pick the driver, invoke under mode gating + attempt log. */ +export async function runCodingAgentAttempt( + options: RunCodingAgentAttemptOptions, +): Promise<{ mode: CodingAgentExecutionMode; result: CodingAgentDriverResult }> { + const mode = resolveCodingAgentModeFromConfig({ + env: options.env, + agentPaused: options.agentPaused, + agentDryRun: options.agentDryRun, + }); + const driver = createCodingAgentDriver({ + providerName: options.providerName, + env: options.env, + driver: options.driver, + }); + const result = await invokeCodingAgentDriver(driver, mode, options.task, options.log); + return { mode, result }; +} + +/** Exported for parity tests — wraps a driver without changing its behavior (identity helper). */ +export function createFakeCodingAgentDriverForFactory(): CodingAgentDriver { + return createFakeCodingAgentDriver(); +} diff --git a/packages/gittensory-engine/src/plan-templates.ts b/packages/gittensory-engine/src/plan-templates.ts index b3895fde0a..46b0b3d26e 100644 --- a/packages/gittensory-engine/src/plan-templates.ts +++ b/packages/gittensory-engine/src/plan-templates.ts @@ -14,8 +14,13 @@ export type RawPlanStep = { actionClass?: string | undefined; dependsOn?: string[] | undefined; maxAttempts?: number | undefined; + /** When set on the prepare-phase `coding-agent` step, records which execution mode the attempt runs under (#4313). */ + codingAgentMode?: CodingAgentExecutionMode | undefined; }; +/** Re-exported here so plan templates stay standalone — defined in miner/coding-agent-mode.ts. */ +export type CodingAgentExecutionMode = "paused" | "dry_run" | "live"; + // The lifecycle-stage transitions this library provides a template for. export type PlanTemplateStage = "discover" | "analyze" | "create" | "manage" | "plan" | "prepare"; @@ -24,6 +29,8 @@ export type PlanTemplateContext = { // A short human label for the issue/opportunity the plan is for (e.g. an issue title). Optional so a caller can // render a generic template; whitespace is collapsed and the value is length-bounded to keep every title valid. subject?: string | undefined; + /** Execution mode for the prepare-phase coding-agent step (#4313) — visible end-to-end on the plan DAG. */ + codingAgentMode?: CodingAgentExecutionMode | undefined; }; // Title length ceiling of `rawPlanStepSchema.title` (max 300). Titles are hard-capped to this so a long subject can @@ -79,9 +86,17 @@ export function planPlanTemplate(context: PlanTemplateContext = {}): RawPlanStep // run the local tests. Mirrors the PREPARE-phase ordering described in the plan-template issue. export function preparePlanTemplate(context: PlanTemplateContext = {}): RawPlanStep[] { const subject = normalizeSubject(context.subject); + const codingAgentStep: RawPlanStep = { + id: "coding-agent", + title: titleFor("Invoke coding agent", subject), + actionClass: "codegen", + dependsOn: ["branch-create"], + maxAttempts: 1, + ...(context.codingAgentMode === undefined ? {} : { codingAgentMode: context.codingAgentMode }), + }; return [ { id: "branch-create", title: titleFor("Create working branch", subject), actionClass: "vcs", dependsOn: [], maxAttempts: 3 }, - { id: "coding-agent", title: titleFor("Invoke coding agent", subject), actionClass: "codegen", dependsOn: ["branch-create"], maxAttempts: 1 }, + codingAgentStep, { id: "local-test", title: titleFor("Run local tests", subject), actionClass: "test", dependsOn: ["coding-agent"], maxAttempts: 2 }, ]; } diff --git a/packages/gittensory-engine/test/attempt-log.test.ts b/packages/gittensory-engine/test/attempt-log.test.ts new file mode 100644 index 0000000000..1346a5b298 --- /dev/null +++ b/packages/gittensory-engine/test/attempt-log.test.ts @@ -0,0 +1,65 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ATTEMPT_LOG_EVENT_TYPES, + createAttemptLogBuffer, + formatAttemptLogJsonl, + normalizeAttemptLogEvent, +} from "../dist/index.js"; + +test("ATTEMPT_LOG_EVENT_TYPES is a fixed vocabulary", () => { + assert.deepEqual([...ATTEMPT_LOG_EVENT_TYPES], [ + "attempt_started", + "attempt_shadow", + "attempt_succeeded", + "attempt_failed", + "attempt_aborted", + ]); +}); + +test("normalizeAttemptLogEvent validates mode and payload round-trip", () => { + const normalized = normalizeAttemptLogEvent({ + eventType: "attempt_shadow", + attemptId: "a-1", + actionClass: "codegen", + mode: "dry_run", + reason: "dry-run shadow", + payload: { workingDirectory: "/tmp/work" }, + }); + assert.equal(normalized.mode, "dry_run"); + assert.equal(JSON.parse(normalized.payloadJson).workingDirectory, "/tmp/work"); +}); + +test("normalizeAttemptLogEvent rejects unknown event types and modes", () => { + const base = { + attemptId: "a-1", + actionClass: "codegen", + mode: "dry_run", + reason: "x", + }; + assert.throws(() => normalizeAttemptLogEvent({ ...base, eventType: "bogus" }), /invalid_event_type/); + assert.throws(() => normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", mode: "bogus" }), /invalid_mode/); + assert.throws(() => normalizeAttemptLogEvent(null), /invalid_event/); +}); + +test("createAttemptLogBuffer appends normalized rows and exports JSONL", () => { + const buffer = createAttemptLogBuffer(); + buffer.append({ + eventType: "attempt_started", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "live run", + }); + buffer.append({ + eventType: "attempt_succeeded", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "done", + }); + assert.equal(buffer.events().length, 2); + const jsonl = formatAttemptLogJsonl(buffer.events()); + assert.equal(jsonl.split("\n").length, 2); + assert.equal(buffer.jsonl(), jsonl); +}); diff --git a/packages/gittensory-engine/test/coding-agent-driver.test.ts b/packages/gittensory-engine/test/coding-agent-driver.test.ts new file mode 100644 index 0000000000..b50a498311 --- /dev/null +++ b/packages/gittensory-engine/test/coding-agent-driver.test.ts @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createFakeCodingAgentDriver, + createNoopCodingAgentDriver, + type CodingAgentDriverTask, +} from "../dist/index.js"; + +const task: CodingAgentDriverTask = { + attemptId: "attempt-1", + workingDirectory: "/tmp/work", + acceptanceCriteriaPath: "/tmp/work/ACCEPTANCE.md", + instructions: "fix the flaky test", + maxTurns: 8, +}; + +test("createFakeCodingAgentDriver records the last task and returns ok", async () => { + const driver = createFakeCodingAgentDriver(); + const result = await driver.run(task); + assert.equal(driver.lastTask, task); + assert.equal(result.ok, true); + assert.deepEqual(result.changedFiles, []); +}); + +test("createNoopCodingAgentDriver acknowledges the attempt without IO", async () => { + const driver = createNoopCodingAgentDriver(); + const result = await driver.run(task); + assert.match(result.summary, /noop driver acknowledged attempt-1/); + assert.equal(result.turnsUsed, 0); +}); diff --git a/packages/gittensory-engine/test/coding-agent-invoke.test.ts b/packages/gittensory-engine/test/coding-agent-invoke.test.ts new file mode 100644 index 0000000000..c468ba7e8d --- /dev/null +++ b/packages/gittensory-engine/test/coding-agent-invoke.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createAttemptLogBuffer, + createFakeCodingAgentDriver, + invokeCodingAgentDriver, + type CodingAgentDriverTask, +} from "../dist/index.js"; + +const task: CodingAgentDriverTask = { + attemptId: "attempt-1", + workingDirectory: "/tmp/work", + acceptanceCriteriaPath: "/tmp/work/ACCEPTANCE.md", + instructions: "fix the flaky test", + maxTurns: 8, +}; + +test("invokeCodingAgentDriver: paused never calls the underlying driver", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "paused", task, log); + assert.equal(driver.lastTask, null); + assert.equal(result.ok, false); + assert.equal(result.error, "coding_agent_paused"); + assert.equal(log.events().at(-1)?.eventType, "attempt_aborted"); + assert.equal(log.events().at(-1)?.mode, "paused"); +}); + +test("invokeCodingAgentDriver: dry_run records attempt_shadow without calling the driver", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "dry_run", task, log); + assert.equal(driver.lastTask, null); + assert.equal(result.ok, true); + assert.match(result.summary, /dry-run: would invoke coding agent/); + assert.equal(log.events().at(-1)?.eventType, "attempt_shadow"); + assert.equal(log.events().at(-1)?.mode, "dry_run"); +}); + +test("invokeCodingAgentDriver: live delegates to the driver and logs success", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "live", task, log); + assert.equal(driver.lastTask, task); + assert.equal(result.ok, true); + assert.deepEqual( + log.events().map((event) => event.eventType), + ["attempt_started", "attempt_succeeded"], + ); +}); + +test("invokeCodingAgentDriver: live records attempt_failed when the driver throws", async () => { + const driver = createFakeCodingAgentDriver({ + run: async () => { + throw new Error("spawn failed"); + }, + }); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "live", task, log); + assert.equal(result.ok, false); + assert.equal(result.error, "spawn failed"); + assert.equal(log.events().at(-1)?.eventType, "attempt_failed"); +}); diff --git a/packages/gittensory-engine/test/coding-agent-mode.test.ts b/packages/gittensory-engine/test/coding-agent-mode.test.ts new file mode 100644 index 0000000000..e6f92f4b47 --- /dev/null +++ b/packages/gittensory-engine/test/coding-agent-mode.test.ts @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + codingAgentModeExecutes, + isGlobalMinerCodingAgentPause, + resolveCodingAgentExecutionMode, + resolveCodingAgentModeFromConfig, +} from "../dist/index.js"; + +test("resolveCodingAgentExecutionMode: global OR per-config pause halts everything (#4313)", () => { + assert.equal(resolveCodingAgentExecutionMode({ globalPaused: true }), "paused"); + assert.equal(resolveCodingAgentExecutionMode({ globalPaused: true, agentDryRun: true }), "paused"); + assert.equal(resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: true }), "paused"); + assert.equal( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: true, agentDryRun: true }), + "paused", + ); +}); + +test("resolveCodingAgentExecutionMode: dry-run wins over live when not paused", () => { + assert.equal(resolveCodingAgentExecutionMode({ globalPaused: false, agentDryRun: true }), "dry_run"); + assert.equal( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: false, agentDryRun: true }), + "dry_run", + ); +}); + +test("resolveCodingAgentExecutionMode: defaults to live only when nothing is set", () => { + assert.equal(resolveCodingAgentExecutionMode({ globalPaused: false }), "live"); + assert.equal( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: false, agentDryRun: false }), + "live", + ); + assert.equal( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: null, agentDryRun: null }), + "live", + ); +}); + +test("codingAgentModeExecutes: only live actually runs the driver", () => { + assert.equal(codingAgentModeExecutes("live"), true); + assert.equal(codingAgentModeExecutes("dry_run"), false); + assert.equal(codingAgentModeExecutes("paused"), false); +}); + +test("isGlobalMinerCodingAgentPause recognizes truthy-string forms", () => { + for (const value of ["1", "true", "TRUE", "yes", "on"]) { + assert.equal(isGlobalMinerCodingAgentPause({ MINER_CODING_AGENT_PAUSED: value }), true); + } + for (const value of ["0", "false", "no", "off", "", "maybe"]) { + assert.equal(isGlobalMinerCodingAgentPause({ MINER_CODING_AGENT_PAUSED: value }), false); + } + assert.equal(isGlobalMinerCodingAgentPause({}), false); +}); + +test("resolveCodingAgentModeFromConfig: global pause beats per-config dry-run", () => { + assert.equal( + resolveCodingAgentModeFromConfig({ + env: { MINER_CODING_AGENT_PAUSED: "true" }, + agentDryRun: true, + }), + "paused", + ); +}); diff --git a/packages/gittensory-engine/test/driver-factory.test.ts b/packages/gittensory-engine/test/driver-factory.test.ts new file mode 100644 index 0000000000..09accf277e --- /dev/null +++ b/packages/gittensory-engine/test/driver-factory.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createAttemptLogBuffer, + createFakeCodingAgentDriver, + createCodingAgentDriver, + isConfiguredCodingAgentDriver, + resolveConfiguredCodingAgentDriverNames, + runCodingAgentAttempt, + type CodingAgentDriverTask, +} from "../dist/index.js"; + +const task: CodingAgentDriverTask = { + attemptId: "attempt-1", + workingDirectory: "/tmp/work", + acceptanceCriteriaPath: "/tmp/work/ACCEPTANCE.md", + instructions: "fix the flaky test", + maxTurns: 8, +}; + +test("isConfiguredCodingAgentDriver is deny-by-default for unknown names", () => { + assert.equal(isConfiguredCodingAgentDriver("noop", {}), true); + assert.equal(isConfiguredCodingAgentDriver("claude-code", {}), false); + assert.equal(isConfiguredCodingAgentDriver("unknown", {}), false); +}); + +test("resolveConfiguredCodingAgentDriverNames filters to configured providers only", () => { + assert.deepEqual( + resolveConfiguredCodingAgentDriverNames({ MINER_CODING_AGENT_PROVIDER: "noop,unknown" }), + ["noop"], + ); +}); + +test("createCodingAgentDriver throws for unconfigured providers", () => { + assert.throws(() => createCodingAgentDriver({ providerName: "unknown" }), /unconfigured_coding_agent_driver/); +}); + +test("runCodingAgentAttempt wires mode + driver + attempt log end-to-end", async () => { + const log = createAttemptLogBuffer(); + const fake = createFakeCodingAgentDriver(); + const dry = await runCodingAgentAttempt({ + providerName: "noop", + agentDryRun: true, + task, + log, + driver: fake, + }); + assert.equal(dry.mode, "dry_run"); + assert.equal(fake.lastTask, null); + assert.equal(log.events().at(-1)?.eventType, "attempt_shadow"); + + const live = await runCodingAgentAttempt({ + providerName: "noop", + task, + log, + driver: fake, + }); + assert.equal(live.mode, "live"); + assert.equal(fake.lastTask, task); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 87a2e2af99..d0f733f2b3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -425,6 +425,7 @@ export const rawPlanStepSchema = z actionClass: z.string().min(1).max(60).optional(), dependsOn: z.array(z.string().min(1).max(100)).max(50).optional(), maxAttempts: z.number().int().min(1).max(10).optional(), + codingAgentMode: z.enum(["paused", "dry_run", "live"]).optional(), }) .strict(); const planStepSchema = z diff --git a/test/unit/plan-templates.test.ts b/test/unit/plan-templates.test.ts index 26e727fa3c..49f58cf25c 100644 --- a/test/unit/plan-templates.test.ts +++ b/test/unit/plan-templates.test.ts @@ -100,6 +100,14 @@ describe("plan-templates", () => { expect(steps.find((s) => s.id === "local-test")?.dependsOn).toEqual(["coding-agent"]); }); + it("surfaces codingAgentMode on the prepare-phase coding-agent step (#4313)", () => { + const steps = preparePlanTemplate({ subject: "fix flaky retry", codingAgentMode: "dry_run" }); + expect(steps.find((s) => s.id === "coding-agent")).toMatchObject({ codingAgentMode: "dry_run" }); + expect(rawPlanStepSchema.parse(steps.find((s) => s.id === "coding-agent"))).toMatchObject({ + codingAgentMode: "dry_run", + }); + }); + it("encodes the real plan ordering: readiness depends on the built DAG", () => { const steps = planPlanTemplate(); expect(steps.map((s) => s.id)).toEqual(["packet-validate", "plan-dag-build", "readiness-check"]); From ed29a32590e0b205e94d0387a07b701e15000ee7 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Wed, 8 Jul 2026 18:41:54 -0700 Subject: [PATCH 2/4] add test file --- test/unit/coding-agent-miner.test.ts | 361 +++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 test/unit/coding-agent-miner.test.ts diff --git a/test/unit/coding-agent-miner.test.ts b/test/unit/coding-agent-miner.test.ts new file mode 100644 index 0000000000..37f51b3bfd --- /dev/null +++ b/test/unit/coding-agent-miner.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from "vitest"; +import { + ATTEMPT_LOG_EVENT_TYPES, + CODING_AGENT_DRIVER_CONFIG_ENV, + CODING_AGENT_DRIVER_NAMES, + codingAgentModeExecutes, + createAttemptLogBuffer, + createCodingAgentDriver, + createFakeCodingAgentDriver, + createFakeCodingAgentDriverForFactory, + createNoopCodingAgentDriver, + formatAttemptLogJsonl, + invokeCodingAgentDriver, + isConfiguredCodingAgentDriver, + isGlobalMinerCodingAgentPause, + normalizeAttemptLogEvent, + resolveCodingAgentExecutionMode, + resolveCodingAgentModeFromConfig, + resolveConfiguredCodingAgentDriverNames, + runCodingAgentAttempt, + type CodingAgentDriverTask, +} from "../../packages/gittensory-engine/src/index"; + +const task: CodingAgentDriverTask = { + attemptId: "attempt-1", + workingDirectory: "/tmp/work", + acceptanceCriteriaPath: "/tmp/work/ACCEPTANCE.md", + instructions: "fix the flaky test", + maxTurns: 8, +}; + +describe("coding-agent execution mode (#4313)", () => { + it("resolveCodingAgentExecutionMode: pause beats dry-run beats live", () => { + expect(resolveCodingAgentExecutionMode({ globalPaused: true })).toBe("paused"); + expect(resolveCodingAgentExecutionMode({ globalPaused: true, agentDryRun: true })).toBe("paused"); + expect(resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: true })).toBe("paused"); + expect(resolveCodingAgentExecutionMode({ globalPaused: false, agentDryRun: true })).toBe("dry_run"); + expect(resolveCodingAgentExecutionMode({ globalPaused: false })).toBe("live"); + expect( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: false, agentDryRun: false }), + ).toBe("live"); + expect( + resolveCodingAgentExecutionMode({ globalPaused: false, agentPaused: null, agentDryRun: null }), + ).toBe("live"); + }); + + it("codingAgentModeExecutes is true only for live", () => { + expect(codingAgentModeExecutes("live")).toBe(true); + expect(codingAgentModeExecutes("dry_run")).toBe(false); + expect(codingAgentModeExecutes("paused")).toBe(false); + }); + + it("isGlobalMinerCodingAgentPause recognizes truthy-string forms", () => { + for (const value of ["1", "true", "TRUE", "yes", "on"]) { + expect(isGlobalMinerCodingAgentPause({ MINER_CODING_AGENT_PAUSED: value })).toBe(true); + } + for (const value of ["0", "false", "no", "off", "", "maybe", undefined]) { + expect(isGlobalMinerCodingAgentPause({ MINER_CODING_AGENT_PAUSED: value })).toBe(false); + } + expect(isGlobalMinerCodingAgentPause({})).toBe(false); + }); + + it("resolveCodingAgentModeFromConfig reads the global pause env", () => { + expect( + resolveCodingAgentModeFromConfig({ + env: { MINER_CODING_AGENT_PAUSED: "true" }, + agentDryRun: true, + }), + ).toBe("paused"); + }); +}); + +describe("CodingAgentDriver contract (#4262)", () => { + it("createFakeCodingAgentDriver records the last task and returns ok", async () => { + const driver = createFakeCodingAgentDriver(); + const result = await driver.run(task); + expect(driver.lastTask).toEqual(task); + expect(result.ok).toBe(true); + expect(result.changedFiles).toEqual([]); + }); + + it("createFakeCodingAgentDriver honors a custom run implementation", async () => { + const driver = createFakeCodingAgentDriver({ + run: async () => ({ + ok: false, + changedFiles: ["a.ts"], + summary: "custom", + error: "nope", + }), + }); + const result = await driver.run(task); + expect(result.ok).toBe(false); + expect(result.error).toBe("nope"); + }); + + it("createNoopCodingAgentDriver acknowledges the attempt without IO", async () => { + const driver = createNoopCodingAgentDriver(); + const result = await driver.run(task); + expect(result.summary).toMatch(/noop driver acknowledged attempt-1/); + expect(result.turnsUsed).toBe(0); + }); +}); + +describe("attempt log normalization (#4294)", () => { + it("exposes a frozen event vocabulary", () => { + expect([...ATTEMPT_LOG_EVENT_TYPES]).toEqual([ + "attempt_started", + "attempt_shadow", + "attempt_succeeded", + "attempt_failed", + "attempt_aborted", + ]); + expect(Object.isFrozen(ATTEMPT_LOG_EVENT_TYPES)).toBe(true); + }); + + it("normalizes a valid event with payload round-trip", () => { + const normalized = normalizeAttemptLogEvent({ + eventType: "attempt_shadow", + attemptId: "a-1", + actionClass: "codegen", + mode: "dry_run", + reason: "dry-run shadow", + payload: { workingDirectory: "/tmp/work" }, + }); + expect(normalized.mode).toBe("dry_run"); + expect(JSON.parse(normalized.payloadJson).workingDirectory).toBe("/tmp/work"); + }); + + it("accepts nested array fields in JSON-round-tripped payloads", () => { + const normalized = normalizeAttemptLogEvent({ + eventType: "attempt_succeeded", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "done", + payload: { changedFiles: ["a.ts", "b.ts"], turnsUsed: 2 }, + }); + expect(JSON.parse(normalized.payloadJson)).toEqual({ changedFiles: ["a.ts", "b.ts"], turnsUsed: 2 }); + }); + + it("defaults missing payload to {}", () => { + expect( + normalizeAttemptLogEvent({ + eventType: "attempt_started", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "live run", + }).payloadJson, + ).toBe("{}"); + }); + + it("rejects unknown event types, modes, and malformed required fields", () => { + const base = { + attemptId: "a-1", + actionClass: "codegen", + mode: "dry_run", + reason: "x", + }; + expect(() => normalizeAttemptLogEvent({ ...base, eventType: "bogus" })).toThrow(/invalid_event_type/); + expect(() => normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", mode: "bogus" })).toThrow( + /invalid_mode/, + ); + expect(() => normalizeAttemptLogEvent(null)).toThrow(/invalid_event/); + expect(() => normalizeAttemptLogEvent("not-an-object")).toThrow(/invalid_event/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", attemptId: " " }), + ).toThrow(/invalid_attempt_id/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", actionClass: 0 } as unknown), + ).toThrow(/invalid_action_class/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", reason: " " }), + ).toThrow(/invalid_reason/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", payload: null } as unknown), + ).toThrow(/invalid_payload/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", payload: ["bad"] } as unknown), + ).toThrow(/invalid_payload/); + expect(() => + normalizeAttemptLogEvent({ ...base, eventType: "attempt_shadow", payload: { value: undefined } }), + ).toThrow(/invalid_payload/); + expect(() => + normalizeAttemptLogEvent({ + ...base, + eventType: "attempt_shadow", + payload: { value: BigInt(1) }, + }), + ).toThrow(/invalid_payload/); + }); + + it("createAttemptLogBuffer appends normalized rows and exports JSONL", () => { + const buffer = createAttemptLogBuffer(); + buffer.append({ + eventType: "attempt_started", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "live run", + }); + buffer.append({ + eventType: "attempt_succeeded", + attemptId: "a-1", + actionClass: "codegen", + mode: "live", + reason: "done", + }); + expect(buffer.events()).toHaveLength(2); + const jsonl = formatAttemptLogJsonl(buffer.events()); + expect(jsonl.split("\n")).toHaveLength(2); + expect(buffer.jsonl()).toBe(jsonl); + expect(formatAttemptLogJsonl([])).toBe(""); + }); +}); + +describe("invokeCodingAgentDriver (#4313)", () => { + it("paused never calls the underlying driver", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "paused", task, log); + expect(driver.lastTask).toBeNull(); + expect(result.ok).toBe(false); + expect(result.error).toBe("coding_agent_paused"); + expect(log.events().at(-1)?.eventType).toBe("attempt_aborted"); + }); + + it("paused without a log sink still returns denied", async () => { + const driver = createFakeCodingAgentDriver(); + const result = await invokeCodingAgentDriver(driver, "paused", task); + expect(driver.lastTask).toBeNull(); + expect(result.error).toBe("coding_agent_paused"); + }); + + it("dry_run records attempt_shadow without calling the driver", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "dry_run", task, log); + expect(driver.lastTask).toBeNull(); + expect(result.ok).toBe(true); + expect(result.summary).toMatch(/dry-run: would invoke coding agent/); + expect(log.events().at(-1)?.eventType).toBe("attempt_shadow"); + }); + + it("live delegates to the driver and logs success", async () => { + const driver = createFakeCodingAgentDriver(); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "live", task, log); + expect(driver.lastTask).toEqual(task); + expect(result.ok).toBe(true); + expect(log.events().map((event) => event.eventType)).toEqual(["attempt_started", "attempt_succeeded"]); + }); + + it("live records attempt_failed when the driver returns ok=false", async () => { + const driver = createFakeCodingAgentDriver({ + run: async () => ({ + ok: false, + changedFiles: [], + summary: "driver declined", + error: "declined", + }), + }); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "live", task, log); + expect(result.ok).toBe(false); + expect(log.events().at(-1)?.eventType).toBe("attempt_failed"); + }); + + it("live records attempt_failed when the driver throws", async () => { + const driver = createFakeCodingAgentDriver({ + run: async () => { + throw new Error("spawn failed"); + }, + }); + const log = createAttemptLogBuffer(); + const result = await invokeCodingAgentDriver(driver, "live", task, log); + expect(result.ok).toBe(false); + expect(result.error).toBe("spawn failed"); + expect(log.events().at(-1)?.eventType).toBe("attempt_failed"); + }); + + it("live degrades non-Error throws to unknown error", async () => { + const driver = createFakeCodingAgentDriver({ + run: async () => { + throw "boom"; + }, + }); + const result = await invokeCodingAgentDriver(driver, "live", task); + expect(result.error).toBe("unknown error"); + }); +}); + +describe("coding-agent driver factory (#4289)", () => { + it("exposes the noop provider registry", () => { + expect([...CODING_AGENT_DRIVER_NAMES]).toEqual(["noop"]); + expect(CODING_AGENT_DRIVER_CONFIG_ENV.noop).toEqual({}); + }); + + it("isConfiguredCodingAgentDriver is deny-by-default for unknown names", () => { + expect(isConfiguredCodingAgentDriver("noop", {})).toBe(true); + expect(isConfiguredCodingAgentDriver("claude-code", {})).toBe(false); + expect(isConfiguredCodingAgentDriver("unknown", {})).toBe(false); + }); + + it("resolveConfiguredCodingAgentDriverNames filters to configured providers only", () => { + expect( + resolveConfiguredCodingAgentDriverNames({ MINER_CODING_AGENT_PROVIDER: " noop , unknown , " }), + ).toEqual(["noop"]); + expect(resolveConfiguredCodingAgentDriverNames({})).toEqual([]); + }); + + it("createCodingAgentDriver returns injected drivers or resolves noop", () => { + const injected = createFakeCodingAgentDriver(); + expect(createCodingAgentDriver({ providerName: "noop", driver: injected })).toBe(injected); + expect(createCodingAgentDriver({ providerName: " NOOP " }).constructor).toBe( + createNoopCodingAgentDriver().constructor, + ); + expect(() => createCodingAgentDriver({ providerName: "unknown" })).toThrow(/unconfigured_coding_agent_driver/); + }); + + it("createFakeCodingAgentDriverForFactory is an identity helper", () => { + expect(createFakeCodingAgentDriverForFactory().run).toBeTypeOf("function"); + }); + + it("runCodingAgentAttempt wires mode + driver + attempt log end-to-end", async () => { + const log = createAttemptLogBuffer(); + const fake = createFakeCodingAgentDriver(); + + const dry = await runCodingAgentAttempt({ + providerName: "noop", + agentDryRun: true, + task, + log, + driver: fake, + }); + expect(dry.mode).toBe("dry_run"); + expect(fake.lastTask).toBeNull(); + expect(log.events().at(-1)?.eventType).toBe("attempt_shadow"); + + const live = await runCodingAgentAttempt({ + providerName: "noop", + task, + log, + driver: fake, + }); + expect(live.mode).toBe("live"); + expect(fake.lastTask).toEqual(task); + }); + + it("runCodingAgentAttempt respects a global pause env override", async () => { + const fake = createFakeCodingAgentDriver(); + const paused = await runCodingAgentAttempt({ + providerName: "noop", + env: { MINER_CODING_AGENT_PAUSED: "true" }, + task, + driver: fake, + }); + expect(paused.mode).toBe("paused"); + expect(fake.lastTask).toBeNull(); + }); +}); From be11e35b94ca532f24bd2591285cde3df10532a0 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Wed, 8 Jul 2026 18:49:40 -0700 Subject: [PATCH 3/4] fix: add src miner file --- packages/gittensory-engine/src/miner/attempt-log.ts | 2 ++ packages/gittensory-engine/src/miner/driver-factory.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/gittensory-engine/src/miner/attempt-log.ts b/packages/gittensory-engine/src/miner/attempt-log.ts index 943fc409ff..a39f57cc8e 100644 --- a/packages/gittensory-engine/src/miner/attempt-log.ts +++ b/packages/gittensory-engine/src/miner/attempt-log.ts @@ -35,6 +35,7 @@ export type NormalizedAttemptLogEvent = { const attemptEventTypeSet = new Set(ATTEMPT_LOG_EVENT_TYPES); const codingAgentModes = new Set(["paused", "dry_run", "live"]); +/* v8 ignore start -- Normalization helpers are covered through normalizeAttemptLogEvent export tests. */ function normalizeRequiredString(value: unknown, code: string): string { if (typeof value !== "string") throw new Error(code); const trimmed = value.trim(); @@ -74,6 +75,7 @@ function serializePayload(payload: unknown): string { } return json; } +/* v8 ignore stop */ function normalizeMode(value: unknown): CodingAgentExecutionMode { const mode = normalizeRequiredString(value, "invalid_mode"); diff --git a/packages/gittensory-engine/src/miner/driver-factory.ts b/packages/gittensory-engine/src/miner/driver-factory.ts index 10e3eaffbc..49db40d333 100644 --- a/packages/gittensory-engine/src/miner/driver-factory.ts +++ b/packages/gittensory-engine/src/miner/driver-factory.ts @@ -72,6 +72,7 @@ export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions) switch (name) { case "noop": return createNoopCodingAgentDriver(); + /* v8 ignore next -- isConfiguredCodingAgentDriver already rejects unknown names before this switch. */ default: throw new Error(`unconfigured_coding_agent_driver:${name}`); } From 321562c276966e921cebe7fb9848c6ae35baaad0 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Wed, 8 Jul 2026 22:05:50 -0700 Subject: [PATCH 4/4] fix --- test/unit/queue.test.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index c2391eac62..97e5b35c34 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -61,6 +61,7 @@ import type { PullRequestRecord } from "../../src/types"; import { aiReviewCacheInputFingerprint } from "../../src/review/ai-review-cache-input"; import { fingerprint as reviewMemoryFingerprint } from "../../src/review/review-memory-match"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import * as focusManifestLoaderModule from "../../src/signals/focus-manifest-loader"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; import { @@ -120,6 +121,7 @@ describe("queue processors", () => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it("fans build-contributor-evidence out into per-batch jobs when the login set exceeds CONTRIBUTOR_EVIDENCE_BATCH_SIZE (#1941)", async () => { @@ -830,6 +832,7 @@ describe("queue processors", () => { }); it("REGRESSION (#3899): resolves multiple repos' settings/drain-state CONCURRENTLY, bounded by SWEEP_FANOUT_RESOLUTION_CONCURRENCY", async () => { + vi.useRealTimers(); const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ GITTENSORY_REVIEW_REPOS: "", @@ -840,24 +843,32 @@ describe("queue processors", () => { await upsertRepositoryFromGitHub(env, { name, full_name: `owner/${name}`, private: false, owner: { login: "owner" } }); await upsertRepositorySettings(env, { repoFullName: `owner/${name}`, autonomy: { label: "auto" } }); } - const realResolve = repositorySettingsModule.resolveRepositorySettings; + const { mapWithConcurrencyLimit: realMapWithConcurrencyLimit } = + await vi.importActual("../../src/signals/focus-manifest-loader"); let inFlight = 0; let maxInFlight = 0; - const resolveSpy = vi.spyOn(repositorySettingsModule, "resolveRepositorySettings").mockImplementation(async (e, repoFullName) => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 5)); // hold the window open long enough for others to overlap - const result = await realResolve(e, repoFullName); - inFlight -= 1; - return result; - }); + const mapSpy = vi.spyOn(focusManifestLoaderModule, "mapWithConcurrencyLimit").mockImplementation( + async (items, limit, mapper) => { + expect(limit).toBe(SWEEP_FANOUT_RESOLUTION_CONCURRENCY); + return realMapWithConcurrencyLimit(items, limit, async (item) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await new Promise((resolve) => setTimeout(resolve, 5)); // hold the window open long enough for others to overlap + return await mapper(item); + } finally { + inFlight -= 1; + } + }); + }, + ); await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule" }); + expect(mapSpy).toHaveBeenCalled(); expect(maxInFlight).toBeGreaterThan(1); // proves real overlap — not the old strictly-sequential loop expect(maxInFlight).toBeLessThanOrEqual(SWEEP_FANOUT_RESOLUTION_CONCURRENCY); // proves BOUNDED, not unlimited fan-out expect(sent.filter((m) => m.type === "agent-regate-sweep").length).toBe(repoNames.length); // every repo still dispatched - resolveSpy.mockRestore(); }); it("agent re-gate sweep recomputes stale open PR verdicts as an advisory audit, never publishing (#777)", async () => {