From 016e061afb88f0ecbb65c80da22cfd66d2a6ad18 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:10:38 -0700 Subject: [PATCH] feat(miner-governor): build production CodingAgentDriver construction (#5131) Closes the gap coding-agent-house-rules.js's own header names explicitly: nothing in packages/gittensory-miner ever constructs a coding-agent driver in production, only test doubles exist. Adds a real child_process-backed spawn (CliSubprocessSpawnFn) and a real driver-construction call site that resolves MINER_CODING_AGENT_PROVIDER and wires house-rule enforcement (#2343) in by default via buildHouseRulesAgentSdkHooks. --- .../lib/coding-agent-construction.d.ts | 15 ++ .../lib/coding-agent-construction.js | 89 ++++++++++ packages/gittensory-miner/package.json | 2 +- .../miner-coding-agent-construction.test.ts | 159 ++++++++++++++++++ 4 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/coding-agent-construction.d.ts create mode 100644 packages/gittensory-miner/lib/coding-agent-construction.js create mode 100644 test/unit/miner-coding-agent-construction.test.ts diff --git a/packages/gittensory-miner/lib/coding-agent-construction.d.ts b/packages/gittensory-miner/lib/coding-agent-construction.d.ts new file mode 100644 index 0000000000..ffaf48c4e6 --- /dev/null +++ b/packages/gittensory-miner/lib/coding-agent-construction.d.ts @@ -0,0 +1,15 @@ +import type { AgentSdkQueryFn, CliSubprocessSpawnFn, CodingAgentDriver } from "@jsonbored/gittensory-engine"; + +export function createRealCliSubprocessSpawn(): CliSubprocessSpawnFn; + +export type ConstructProductionCodingAgentDriverOptions = { + spawn?: CliSubprocessSpawnFn; + query?: AgentSdkQueryFn; + houseRulesConfig?: unknown; + houseRulesOptions?: unknown; +}; + +export function constructProductionCodingAgentDriver( + env: Record, + options?: ConstructProductionCodingAgentDriverOptions, +): CodingAgentDriver; diff --git a/packages/gittensory-miner/lib/coding-agent-construction.js b/packages/gittensory-miner/lib/coding-agent-construction.js new file mode 100644 index 0000000000..0609772ba5 --- /dev/null +++ b/packages/gittensory-miner/lib/coding-agent-construction.js @@ -0,0 +1,89 @@ +// Production coding-agent driver construction (#5131, Wave 3.5 follow-up to #2337/#2343). Closes the gap +// coding-agent-house-rules.js's own header names explicitly: "nothing in this package constructs a +// coding-agent driver in production yet ... that is separate, larger follow-up work." This module IS that +// call site -- it provides a real `child_process`-backed spawn (mirroring src/selfhost/ai.ts's `defaultSpawn`, +// simplified to the engine's smaller `CliSubprocessSpawnFn` contract: no `firstOutputTimeoutMs`/`input`, since +// those are reviewer-CLI-specific concerns this driver doesn't share) and resolves + constructs a real +// `CodingAgentDriver` from `MINER_CODING_AGENT_PROVIDER`, with house-rule enforcement (#2343) wired in by +// default via `buildHouseRulesAgentSdkHooks` -- a caller never has to remember to attach it by hand. + +import { spawn as nodeSpawn } from "node:child_process"; +import { createCodingAgentDriver, resolveFirstConfiguredCodingAgentDriverName } from "@jsonbored/gittensory-engine"; +import { buildHouseRulesAgentSdkHooks } from "./coding-agent-house-rules.js"; + +/** + * Real `child_process.spawn`-backed implementation of the engine's `CliSubprocessSpawnFn` contract. Captures + * stdout/stderr and RESOLVES (never rejects) on timeout or spawn error, so the caller always sees whatever + * output accumulated rather than an unhandled rejection -- mirrors `src/selfhost/ai.ts`'s `defaultSpawn`'s own + * resolve-not-reject rationale (a killed/errored subprocess's partial output may hold the real diagnosable + * error, e.g. an auth failure line on stderr). + * + * @returns {import("@jsonbored/gittensory-engine").CliSubprocessSpawnFn} + */ +export function createRealCliSubprocessSpawn() { + return (cmd, args, opts) => + new Promise((resolve) => { + const child = nodeSpawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + // Unlike src/selfhost/ai.ts's defaultSpawn (a fixed ~120s default, genuinely untestable without a real + // wait), `opts.timeoutMs` here is always CALLER-supplied per CliSubprocessSpawnFn's contract -- a test can + // pass a short value against a genuinely long-lived child, so this path is exercised directly rather than + // v8-ignored. No "already settled" guard is needed: Promise resolution is idempotent (a second `resolve()` + // is a no-op) and clearing an already-fired timer is a harmless no-op too, so `close`/`error` firing after + // the timeout already resolved is safe without extra bookkeeping. + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ stdout, code: null, stderr, timedOut: true }); + }, opts.timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + // A spawn-level error (e.g. ENOENT) fires before the child ever produces output, so `stderr` is always + // "" here in practice; Node guarantees this listener receives a real Error with `.message` (the + // documented contract for ChildProcess's own "error" event), so no optional chaining/fallback is needed. + clearTimeout(timer); + resolve({ stdout, code: null, stderr: err.message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ stdout, code, stderr }); + }); + }); +} + +/** + * Resolve `MINER_CODING_AGENT_PROVIDER` from `env` and construct a REAL, production `CodingAgentDriver` — + * house-rule-enforced by default (#2343) via `buildHouseRulesAgentSdkHooks`, matching the same + * automatic-enforcement guarantee `runHouseRulesEnforcedCodingAgentAttempt` gives task-level callers, but at + * the raw driver-construction level `attempt-runner.js`'s `deps.driver` actually needs. + * + * Fails closed (throws) when no provider is configured, or when a CLI provider is selected without a real + * spawn available — never silently falls back to a driver that can never run. + * + * @param {Record} env + * @param {{ + * spawn?: import("@jsonbored/gittensory-engine").CliSubprocessSpawnFn, + * query?: import("@jsonbored/gittensory-engine").AgentSdkQueryFn, + * houseRulesConfig?: unknown, + * houseRulesOptions?: unknown, + * }} [options] + * @returns {import("@jsonbored/gittensory-engine").CodingAgentDriver} + */ +export function constructProductionCodingAgentDriver(env, options = {}) { + const providerName = resolveFirstConfiguredCodingAgentDriverName(env); + if (!providerName) { + throw new Error("unconfigured_coding_agent_driver:no_provider_in_MINER_CODING_AGENT_PROVIDER"); + } + return createCodingAgentDriver({ + providerName, + env, + spawn: options.spawn ?? createRealCliSubprocessSpawn(), + ...(options.query !== undefined ? { query: options.query } : {}), + hooks: buildHouseRulesAgentSdkHooks(options.houseRulesConfig, options.houseRulesOptions), + }); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 46e9962cf0..0e094b9bf8 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.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-house-rules.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/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.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-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.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/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/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.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/run-state-cli.js && node --check lib/run-state.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 lib/attempt-log.js && node --check lib/attempt-runner.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/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/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.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-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.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/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/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.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/run-state-cli.js && node --check lib/run-state.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/miner-coding-agent-construction.test.ts b/test/unit/miner-coding-agent-construction.test.ts new file mode 100644 index 0000000000..2927abe210 --- /dev/null +++ b/test/unit/miner-coding-agent-construction.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { createRealCliSubprocessSpawn, constructProductionCodingAgentDriver } from "../../packages/gittensory-miner/lib/coding-agent-construction.js"; +import type { AgentSdkQueryFn, CodingAgentDriverTask } from "../../packages/gittensory-engine/src/index"; + +const task: CodingAgentDriverTask = { + attemptId: "attempt-1", + workingDirectory: "/tmp/worktrees/attempt-1", + acceptanceCriteriaPath: "/tmp/worktrees/attempt-1/ACCEPTANCE-CRITERIA.md", + instructions: "Apply the fix described in ACCEPTANCE-CRITERIA.md.", + maxTurns: 4, +}; + +function assistantResult(): Record { + return { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done" }; +} + +function queryCapturing(captured: { input?: Parameters[0] }): AgentSdkQueryFn { + return (input) => { + captured.input = input; + return (async function* () { + yield assistantResult(); + })(); + }; +} + +describe("createRealCliSubprocessSpawn (#5131)", () => { + it("captures stdout and a zero exit code from a real short-lived process", async () => { + const spawnFn = createRealCliSubprocessSpawn(); + const result = await spawnFn(process.execPath, ["-e", "process.stdout.write('hello')"], { + cwd: process.cwd(), + env: process.env, + timeoutMs: 5000, + }); + expect(result).toEqual({ stdout: "hello", code: 0, stderr: "" }); + }); + + it("captures stderr and a non-zero exit code", async () => { + const spawnFn = createRealCliSubprocessSpawn(); + const result = await spawnFn(process.execPath, ["-e", "process.stderr.write('oops'); process.exit(2)"], { + cwd: process.cwd(), + env: process.env, + timeoutMs: 5000, + }); + expect(result.code).toBe(2); + expect(result.stderr).toBe("oops"); + }); + + it("resolves (never rejects) with code:null and the error message on stderr when the command doesn't exist", async () => { + const spawnFn = createRealCliSubprocessSpawn(); + const result = await spawnFn("this-command-definitely-does-not-exist-xyz", [], { + cwd: process.cwd(), + env: process.env, + timeoutMs: 5000, + }); + expect(result.code).toBeNull(); + expect(result.stderr).toContain("this-command-definitely-does-not-exist-xyz"); + }); + + it("kills a long-lived process and resolves with timedOut:true when the caller-supplied timeout elapses", async () => { + const spawnFn = createRealCliSubprocessSpawn(); + const result = await spawnFn(process.execPath, ["-e", "setInterval(() => {}, 50)"], { + cwd: process.cwd(), + env: process.env, + timeoutMs: 100, + }); + expect(result.timedOut).toBe(true); + expect(result.code).toBeNull(); + }); +}); + +describe("constructProductionCodingAgentDriver (#5131)", () => { + it("fails closed (throws) when MINER_CODING_AGENT_PROVIDER is unset", () => { + expect(() => constructProductionCodingAgentDriver({})).toThrow(/unconfigured_coding_agent_driver/); + }); + + it("fails closed when every configured name is unknown (deny-by-default)", () => { + expect(() => constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "bogus" })).toThrow( + /unconfigured_coding_agent_driver/, + ); + }); + + it("resolves the FIRST configured name from a comma-separated list, skipping unknown entries", async () => { + const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "bogus,noop" }); + const result = await driver.run(task); + expect(result.ok).toBe(true); + }); + + it("constructs a real, working driver for the noop provider (no spawn required)", async () => { + const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "noop" }); + const result = await driver.run(task); + expect(result.ok).toBe(true); + expect(result.changedFiles).toEqual([]); + }); + + it("constructs a claude-cli driver wired to an injected spawn, without invoking it during construction", async () => { + const calls: Array<{ cmd: string; args: readonly string[] }> = []; + const driver = constructProductionCodingAgentDriver( + { MINER_CODING_AGENT_PROVIDER: "claude-cli" }, + { + spawn: async (cmd, args) => { + calls.push({ cmd, args }); + return { stdout: "done", code: 0 }; + }, + }, + ); + expect(calls).toHaveLength(0); // construction alone must not spawn anything + const result = await driver.run(task); + expect(calls).toHaveLength(1); + expect(calls[0]!.cmd).toBe("claude"); + expect(result.ok).toBe(true); + }); + + it("defaults to a real (non-injected) spawn for a CLI provider when the caller supplies none", () => { + // Construction alone must succeed without ever invoking the real spawn (a real "claude" binary is not + // present in CI) — proving the `options.spawn ?? createRealCliSubprocessSpawn()` default branch is taken. + const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "claude-cli" }); + expect(typeof driver.run).toBe("function"); + }); + + it("wires house-rule enforcement into the agent-sdk provider's hooks by default", async () => { + const captured: { input?: Parameters[0] } = {}; + const driver = constructProductionCodingAgentDriver( + { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }, + { query: queryCapturing(captured) }, + ); + const result = await driver.run(task); + expect(result.ok).toBe(true); + + const hooks = captured.input!.options.hooks as { PreToolUse: Array<{ hooks: Array<(input: unknown) => Promise> }> }; + expect(Object.keys(hooks)).toEqual(["PreToolUse"]); + // Prove it's a REAL, enforcing hook, not an empty placeholder shape. + const callback = hooks.PreToolUse[0]!.hooks[0]!; + const denied = await callback({ tool_name: "Read", tool_input: { file_path: ".env" } }); + expect(denied).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } }); + }); + + it("threads houseRulesConfig/houseRulesOptions into the defaulted hook", async () => { + const append = vi.fn(); + const captured: { input?: Parameters[0] } = {}; + const driver = constructProductionCodingAgentDriver( + { MINER_CODING_AGENT_PROVIDER: "agent-sdk" }, + { + query: queryCapturing(captured), + houseRulesConfig: { repoFullName: "acme/widgets" }, + houseRulesOptions: { append }, + }, + ); + await driver.run(task); + + const hooks = captured.input!.options.hooks as { PreToolUse: Array<{ hooks: Array<(input: unknown) => Promise> }> }; + await hooks.PreToolUse[0]!.hooks[0]!({ tool_name: "Read", tool_input: { file_path: ".env" } }); + expect(append).toHaveBeenCalledWith(expect.objectContaining({ repoFullName: "acme/widgets" })); + }); +});