Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/gittensory-miner/lib/coding-agent-construction.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
options?: ConstructProductionCodingAgentDriverOptions,
): CodingAgentDriver;
89 changes: 89 additions & 0 deletions packages/gittensory-miner/lib/coding-agent-construction.js
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>} 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),
});
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
Expand Down
159 changes: 159 additions & 0 deletions test/unit/miner-coding-agent-construction.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done" };
}

function queryCapturing(captured: { input?: Parameters<AgentSdkQueryFn>[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<AgentSdkQueryFn>[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<unknown>> }> };
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<AgentSdkQueryFn>[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<unknown>> }> };
await hooks.PreToolUse[0]!.hooks[0]!({ tool_name: "Read", tool_input: { file_path: ".env" } });
expect(append).toHaveBeenCalledWith(expect.objectContaining({ repoFullName: "acme/widgets" }));
});
});