Skip to content

Commit 9396dc8

Browse files
authored
feat(miner-governor): build production CodingAgentDriver construction (#5131) (#5138)
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.
1 parent d1cb4e5 commit 9396dc8

4 files changed

Lines changed: 264 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { AgentSdkQueryFn, CliSubprocessSpawnFn, CodingAgentDriver } from "@jsonbored/gittensory-engine";
2+
3+
export function createRealCliSubprocessSpawn(): CliSubprocessSpawnFn;
4+
5+
export type ConstructProductionCodingAgentDriverOptions = {
6+
spawn?: CliSubprocessSpawnFn;
7+
query?: AgentSdkQueryFn;
8+
houseRulesConfig?: unknown;
9+
houseRulesOptions?: unknown;
10+
};
11+
12+
export function constructProductionCodingAgentDriver(
13+
env: Record<string, string | undefined>,
14+
options?: ConstructProductionCodingAgentDriverOptions,
15+
): CodingAgentDriver;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Production coding-agent driver construction (#5131, Wave 3.5 follow-up to #2337/#2343). Closes the gap
2+
// coding-agent-house-rules.js's own header names explicitly: "nothing in this package constructs a
3+
// coding-agent driver in production yet ... that is separate, larger follow-up work." This module IS that
4+
// call site -- it provides a real `child_process`-backed spawn (mirroring src/selfhost/ai.ts's `defaultSpawn`,
5+
// simplified to the engine's smaller `CliSubprocessSpawnFn` contract: no `firstOutputTimeoutMs`/`input`, since
6+
// those are reviewer-CLI-specific concerns this driver doesn't share) and resolves + constructs a real
7+
// `CodingAgentDriver` from `MINER_CODING_AGENT_PROVIDER`, with house-rule enforcement (#2343) wired in by
8+
// default via `buildHouseRulesAgentSdkHooks` -- a caller never has to remember to attach it by hand.
9+
10+
import { spawn as nodeSpawn } from "node:child_process";
11+
import { createCodingAgentDriver, resolveFirstConfiguredCodingAgentDriverName } from "@jsonbored/gittensory-engine";
12+
import { buildHouseRulesAgentSdkHooks } from "./coding-agent-house-rules.js";
13+
14+
/**
15+
* Real `child_process.spawn`-backed implementation of the engine's `CliSubprocessSpawnFn` contract. Captures
16+
* stdout/stderr and RESOLVES (never rejects) on timeout or spawn error, so the caller always sees whatever
17+
* output accumulated rather than an unhandled rejection -- mirrors `src/selfhost/ai.ts`'s `defaultSpawn`'s own
18+
* resolve-not-reject rationale (a killed/errored subprocess's partial output may hold the real diagnosable
19+
* error, e.g. an auth failure line on stderr).
20+
*
21+
* @returns {import("@jsonbored/gittensory-engine").CliSubprocessSpawnFn}
22+
*/
23+
export function createRealCliSubprocessSpawn() {
24+
return (cmd, args, opts) =>
25+
new Promise((resolve) => {
26+
const child = nodeSpawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ["ignore", "pipe", "pipe"] });
27+
let stdout = "";
28+
let stderr = "";
29+
// Unlike src/selfhost/ai.ts's defaultSpawn (a fixed ~120s default, genuinely untestable without a real
30+
// wait), `opts.timeoutMs` here is always CALLER-supplied per CliSubprocessSpawnFn's contract -- a test can
31+
// pass a short value against a genuinely long-lived child, so this path is exercised directly rather than
32+
// v8-ignored. No "already settled" guard is needed: Promise resolution is idempotent (a second `resolve()`
33+
// is a no-op) and clearing an already-fired timer is a harmless no-op too, so `close`/`error` firing after
34+
// the timeout already resolved is safe without extra bookkeeping.
35+
const timer = setTimeout(() => {
36+
child.kill("SIGKILL");
37+
resolve({ stdout, code: null, stderr, timedOut: true });
38+
}, opts.timeoutMs);
39+
child.stdout?.on("data", (chunk) => {
40+
stdout += chunk.toString("utf8");
41+
});
42+
child.stderr?.on("data", (chunk) => {
43+
stderr += chunk.toString("utf8");
44+
});
45+
child.on("error", (err) => {
46+
// A spawn-level error (e.g. ENOENT) fires before the child ever produces output, so `stderr` is always
47+
// "" here in practice; Node guarantees this listener receives a real Error with `.message` (the
48+
// documented contract for ChildProcess's own "error" event), so no optional chaining/fallback is needed.
49+
clearTimeout(timer);
50+
resolve({ stdout, code: null, stderr: err.message });
51+
});
52+
child.on("close", (code) => {
53+
clearTimeout(timer);
54+
resolve({ stdout, code, stderr });
55+
});
56+
});
57+
}
58+
59+
/**
60+
* Resolve `MINER_CODING_AGENT_PROVIDER` from `env` and construct a REAL, production `CodingAgentDriver` —
61+
* house-rule-enforced by default (#2343) via `buildHouseRulesAgentSdkHooks`, matching the same
62+
* automatic-enforcement guarantee `runHouseRulesEnforcedCodingAgentAttempt` gives task-level callers, but at
63+
* the raw driver-construction level `attempt-runner.js`'s `deps.driver` actually needs.
64+
*
65+
* Fails closed (throws) when no provider is configured, or when a CLI provider is selected without a real
66+
* spawn available — never silently falls back to a driver that can never run.
67+
*
68+
* @param {Record<string, string | undefined>} env
69+
* @param {{
70+
* spawn?: import("@jsonbored/gittensory-engine").CliSubprocessSpawnFn,
71+
* query?: import("@jsonbored/gittensory-engine").AgentSdkQueryFn,
72+
* houseRulesConfig?: unknown,
73+
* houseRulesOptions?: unknown,
74+
* }} [options]
75+
* @returns {import("@jsonbored/gittensory-engine").CodingAgentDriver}
76+
*/
77+
export function constructProductionCodingAgentDriver(env, options = {}) {
78+
const providerName = resolveFirstConfiguredCodingAgentDriverName(env);
79+
if (!providerName) {
80+
throw new Error("unconfigured_coding_agent_driver:no_provider_in_MINER_CODING_AGENT_PROVIDER");
81+
}
82+
return createCodingAgentDriver({
83+
providerName,
84+
env,
85+
spawn: options.spawn ?? createRealCliSubprocessSpawn(),
86+
...(options.query !== undefined ? { query: options.query } : {}),
87+
hooks: buildHouseRulesAgentSdkHooks(options.houseRulesConfig, options.houseRulesOptions),
88+
});
89+
}

packages/gittensory-miner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"expected-engine.version"
3333
],
3434
"scripts": {
35-
"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"
35+
"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"
3636
},
3737
"dependencies": {
3838
"@jsonbored/gittensory-engine": "*"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("@jsonbored/gittensory-engine", async () => {
4+
return import("../../packages/gittensory-engine/src/index");
5+
});
6+
7+
import { createRealCliSubprocessSpawn, constructProductionCodingAgentDriver } from "../../packages/gittensory-miner/lib/coding-agent-construction.js";
8+
import type { AgentSdkQueryFn, CodingAgentDriverTask } from "../../packages/gittensory-engine/src/index";
9+
10+
const task: CodingAgentDriverTask = {
11+
attemptId: "attempt-1",
12+
workingDirectory: "/tmp/worktrees/attempt-1",
13+
acceptanceCriteriaPath: "/tmp/worktrees/attempt-1/ACCEPTANCE-CRITERIA.md",
14+
instructions: "Apply the fix described in ACCEPTANCE-CRITERIA.md.",
15+
maxTurns: 4,
16+
};
17+
18+
function assistantResult(): Record<string, unknown> {
19+
return { type: "result", subtype: "success", is_error: false, num_turns: 1, result: "done" };
20+
}
21+
22+
function queryCapturing(captured: { input?: Parameters<AgentSdkQueryFn>[0] }): AgentSdkQueryFn {
23+
return (input) => {
24+
captured.input = input;
25+
return (async function* () {
26+
yield assistantResult();
27+
})();
28+
};
29+
}
30+
31+
describe("createRealCliSubprocessSpawn (#5131)", () => {
32+
it("captures stdout and a zero exit code from a real short-lived process", async () => {
33+
const spawnFn = createRealCliSubprocessSpawn();
34+
const result = await spawnFn(process.execPath, ["-e", "process.stdout.write('hello')"], {
35+
cwd: process.cwd(),
36+
env: process.env,
37+
timeoutMs: 5000,
38+
});
39+
expect(result).toEqual({ stdout: "hello", code: 0, stderr: "" });
40+
});
41+
42+
it("captures stderr and a non-zero exit code", async () => {
43+
const spawnFn = createRealCliSubprocessSpawn();
44+
const result = await spawnFn(process.execPath, ["-e", "process.stderr.write('oops'); process.exit(2)"], {
45+
cwd: process.cwd(),
46+
env: process.env,
47+
timeoutMs: 5000,
48+
});
49+
expect(result.code).toBe(2);
50+
expect(result.stderr).toBe("oops");
51+
});
52+
53+
it("resolves (never rejects) with code:null and the error message on stderr when the command doesn't exist", async () => {
54+
const spawnFn = createRealCliSubprocessSpawn();
55+
const result = await spawnFn("this-command-definitely-does-not-exist-xyz", [], {
56+
cwd: process.cwd(),
57+
env: process.env,
58+
timeoutMs: 5000,
59+
});
60+
expect(result.code).toBeNull();
61+
expect(result.stderr).toContain("this-command-definitely-does-not-exist-xyz");
62+
});
63+
64+
it("kills a long-lived process and resolves with timedOut:true when the caller-supplied timeout elapses", async () => {
65+
const spawnFn = createRealCliSubprocessSpawn();
66+
const result = await spawnFn(process.execPath, ["-e", "setInterval(() => {}, 50)"], {
67+
cwd: process.cwd(),
68+
env: process.env,
69+
timeoutMs: 100,
70+
});
71+
expect(result.timedOut).toBe(true);
72+
expect(result.code).toBeNull();
73+
});
74+
});
75+
76+
describe("constructProductionCodingAgentDriver (#5131)", () => {
77+
it("fails closed (throws) when MINER_CODING_AGENT_PROVIDER is unset", () => {
78+
expect(() => constructProductionCodingAgentDriver({})).toThrow(/unconfigured_coding_agent_driver/);
79+
});
80+
81+
it("fails closed when every configured name is unknown (deny-by-default)", () => {
82+
expect(() => constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "bogus" })).toThrow(
83+
/unconfigured_coding_agent_driver/,
84+
);
85+
});
86+
87+
it("resolves the FIRST configured name from a comma-separated list, skipping unknown entries", async () => {
88+
const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "bogus,noop" });
89+
const result = await driver.run(task);
90+
expect(result.ok).toBe(true);
91+
});
92+
93+
it("constructs a real, working driver for the noop provider (no spawn required)", async () => {
94+
const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "noop" });
95+
const result = await driver.run(task);
96+
expect(result.ok).toBe(true);
97+
expect(result.changedFiles).toEqual([]);
98+
});
99+
100+
it("constructs a claude-cli driver wired to an injected spawn, without invoking it during construction", async () => {
101+
const calls: Array<{ cmd: string; args: readonly string[] }> = [];
102+
const driver = constructProductionCodingAgentDriver(
103+
{ MINER_CODING_AGENT_PROVIDER: "claude-cli" },
104+
{
105+
spawn: async (cmd, args) => {
106+
calls.push({ cmd, args });
107+
return { stdout: "done", code: 0 };
108+
},
109+
},
110+
);
111+
expect(calls).toHaveLength(0); // construction alone must not spawn anything
112+
const result = await driver.run(task);
113+
expect(calls).toHaveLength(1);
114+
expect(calls[0]!.cmd).toBe("claude");
115+
expect(result.ok).toBe(true);
116+
});
117+
118+
it("defaults to a real (non-injected) spawn for a CLI provider when the caller supplies none", () => {
119+
// Construction alone must succeed without ever invoking the real spawn (a real "claude" binary is not
120+
// present in CI) — proving the `options.spawn ?? createRealCliSubprocessSpawn()` default branch is taken.
121+
const driver = constructProductionCodingAgentDriver({ MINER_CODING_AGENT_PROVIDER: "claude-cli" });
122+
expect(typeof driver.run).toBe("function");
123+
});
124+
125+
it("wires house-rule enforcement into the agent-sdk provider's hooks by default", async () => {
126+
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
127+
const driver = constructProductionCodingAgentDriver(
128+
{ MINER_CODING_AGENT_PROVIDER: "agent-sdk" },
129+
{ query: queryCapturing(captured) },
130+
);
131+
const result = await driver.run(task);
132+
expect(result.ok).toBe(true);
133+
134+
const hooks = captured.input!.options.hooks as { PreToolUse: Array<{ hooks: Array<(input: unknown) => Promise<unknown>> }> };
135+
expect(Object.keys(hooks)).toEqual(["PreToolUse"]);
136+
// Prove it's a REAL, enforcing hook, not an empty placeholder shape.
137+
const callback = hooks.PreToolUse[0]!.hooks[0]!;
138+
const denied = await callback({ tool_name: "Read", tool_input: { file_path: ".env" } });
139+
expect(denied).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } });
140+
});
141+
142+
it("threads houseRulesConfig/houseRulesOptions into the defaulted hook", async () => {
143+
const append = vi.fn();
144+
const captured: { input?: Parameters<AgentSdkQueryFn>[0] } = {};
145+
const driver = constructProductionCodingAgentDriver(
146+
{ MINER_CODING_AGENT_PROVIDER: "agent-sdk" },
147+
{
148+
query: queryCapturing(captured),
149+
houseRulesConfig: { repoFullName: "acme/widgets" },
150+
houseRulesOptions: { append },
151+
},
152+
);
153+
await driver.run(task);
154+
155+
const hooks = captured.input!.options.hooks as { PreToolUse: Array<{ hooks: Array<(input: unknown) => Promise<unknown>> }> };
156+
await hooks.PreToolUse[0]!.hooks[0]!({ tool_name: "Read", tool_input: { file_path: ".env" } });
157+
expect(append).toHaveBeenCalledWith(expect.objectContaining({ repoFullName: "acme/widgets" }));
158+
});
159+
});

0 commit comments

Comments
 (0)