Skip to content

Commit e10abf2

Browse files
authored
feat(miner): run the target repo's own test/lint/build commands before opening a PR (#8819)
* feat(miner): run the target repo's own test/lint/build commands before opening a PR (#8807) Nothing independently verified a coding agent's work before submission: the only verification module (engine lint-guard) is hardcoded to loopover's own monorepo commands and never passed in production, and coding-task-spec's validation guidance only TELLS the agent which commands to run. A known-bad change passed every AMS-side gate on the agent's self-attestation alone. - target-repo-verification.ts: runs stack-detection's already-inferred commands (test → lint → build, highest signal first) from the attempt's worktree, per-command timeout (10 min), stop at first failure, bounded output tail. An undetected stack or empty command set SKIPS (recorded, never a failure) — the gate is only as smart as detection, and empty detection must not block repos with unconventional tooling. - attempt-runner: the gate runs after handoff + kill-switch recheck and BEFORE the freshness read (a failing build never spends GitHub budget); a failure returns the new verification_failed outcome — the attempt never submits, the worktree is retained for postmortem, and the existing not-submitted notification plumbing carries the reason. Deliberately not re-entering the iterate loop in this change (the loop's own self-review iterations already ran; never-submit-known-bad is the trust win) — loop feedback is the tracked follow-up on the issue. - attempt-cli binds the worktree-scoped thunk (same stack detection the agent's guidance rendered), with MINER_SKIP_TARGET_REPO_VERIFICATION as the escape hatch for suites exceeding the per-command bound. * chore(miner): regenerate env reference for MINER_SKIP_TARGET_REPO_VERIFICATION * fix(miner): kill the whole verification process group on timeout and settle bounded The default verification spawn killed only the shell at the timeout; on Linux the command's descendants survive, keep the stdio pipes open, and stall the close event (observed as a 30s hang in CI). Spawn detached, kill the negative pid, and add a bounded post-kill settle so the gate can never outlive its per-command timeout even if an orphan escapes the group. Also fix the queue.test.ts/queue-2.test.ts fetch stubs whose generic {} fallback broke the close-explanation marker search (the comment list endpoint must return an array) — a latent mock-shape gap surfaced by routing enforced closes through createOrUpdateCloseExplanationComment.
1 parent 33bacda commit e10abf2

10 files changed

Lines changed: 440 additions & 0 deletions

File tree

apps/loopover-ui/src/lib/ams-env-reference.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
241241
firstReference: "packages/loopover-engine/src/miner/driver-factory.ts",
242242
defaultValue: null,
243243
},
244+
{
245+
name: "MINER_SKIP_TARGET_REPO_VERIFICATION",
246+
firstReference: "lib/attempt-cli.ts",
247+
defaultValue: "",
248+
},
244249
];
245250

246251
export const AMS_ENV_REFERENCE_MARKDOWN = [
@@ -297,5 +302,6 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
297302
'| `MINER_CODING_AGENT_PAUSED` | `packages/loopover-engine/src/miner/coding-agent-mode.ts` | `""` |',
298303
'| `MINER_CODING_AGENT_PROVIDER` | `lib/laptop-init.ts` | `""` |',
299304
"| `MINER_CODING_AGENT_TIMEOUT_MS` | `packages/loopover-engine/src/miner/driver-factory.ts` | (none) |",
305+
'| `MINER_SKIP_TARGET_REPO_VERIFICATION` | `lib/attempt-cli.ts` | `""` |',
300306
"",
301307
].join("\n");

packages/loopover-miner/docs/env-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
5151
| `MINER_CODING_AGENT_PAUSED` | `packages/loopover-engine/src/miner/coding-agent-mode.ts` | `""` |
5252
| `MINER_CODING_AGENT_PROVIDER` | `lib/laptop-init.ts` | `""` |
5353
| `MINER_CODING_AGENT_TIMEOUT_MS` | `packages/loopover-engine/src/miner/driver-factory.ts` | (none) |
54+
| `MINER_SKIP_TARGET_REPO_VERIFICATION` | `lib/attempt-cli.ts` | `""` |

packages/loopover-miner/lib/attempt-cli.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { isValidRepoSegment } from "./repo-clone.js";
4646
import { REJECTION_REASON_AI_USAGE_POLICY_BAN, REJECTION_REASON_OWN_SUBMISSION_REJECTED, resolveOwnOpenPrForIssue, resolveRejectionSignaled } from "./rejection-signal.js";
4747
import { initDenyHookSynthesisStore } from "./deny-hook-synthesis.js";
4848
import type { DenyRule } from "@loopover/engine";
49+
import { runTargetRepoVerification } from "./target-repo-verification.js";
50+
import { detectRepoStack } from "./stack-detection.js";
4951
import type { resolveRejectionSignaled as ResolveRejectionSignaledFn } from "./rejection-signal.js";
5052
import { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js";
5153
import type {
@@ -145,6 +147,8 @@ export type RunAttemptOptions = {
145147
resolveRejectionSignaled?: typeof ResolveRejectionSignaledFn;
146148
// #8808: injection seam for the own-open-PR idempotency guard, mirroring resolveRejectionSignaled above.
147149
resolveOwnOpenPrForIssue?: typeof resolveOwnOpenPrForIssue;
150+
// #8807: injection seam for the target-repo verification gate, mirroring the resolver seams above.
151+
runTargetRepoVerification?: typeof runTargetRepoVerification;
148152
fetchImpl?: SelfReviewContextFetch;
149153
prepareAttemptWorktree?: typeof PrepareAttemptWorktreeFn;
150154
cleanupAttemptWorktree?: typeof CleanupAttemptWorktreeFn;
@@ -739,6 +743,9 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {}
739743
};
740744
};
741745

746+
// #8807: captured as a const here (where the !ok early-return has already narrowed the union) because
747+
// the verification thunk below closes over it — TS drops narrowing on a mutable binding inside closures.
748+
const attemptWorktreePath = worktreeResult.worktreePath;
742749
const loopInput = buildAttemptLoopInput({
743750
codingTaskSpec,
744751
reviewContext,
@@ -872,6 +879,18 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {}
872879
...deps,
873880
shouldAbort,
874881
resolveKillSwitchScope: () => resolveLiveKillSwitch().scope,
882+
// #8807: pre-bound target-repo verification against THIS attempt's worktree, using the same stack
883+
// detection the agent's own validation guidance rendered. Opt-out escape hatch for repos whose
884+
// suites exceed the per-command bound; the gate itself skips (never fails) on an undetected stack.
885+
...(/^(1|true|yes|on)$/i.test((env.MINER_SKIP_TARGET_REPO_VERIFICATION ?? "").trim())
886+
? {}
887+
: {
888+
verifyTargetRepo: () =>
889+
(options.runTargetRepoVerification ?? runTargetRepoVerification)({
890+
worktreeDir: attemptWorktreePath,
891+
stack: detectRepoStack(attemptWorktreePath),
892+
}),
893+
}),
875894
},
876895
);
877896
} catch (error) {

packages/loopover-miner/lib/attempt-runner.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export type AttemptInput = {
6060
};
6161

6262
export type AttemptDeps = {
63+
/** #8807: pre-bound target-repo verification (worktree + detected stack captured by the caller). Runs the
64+
* TARGET repo's own test/lint/build commands after handoff and BEFORE any submission read/write — a
65+
* failed verification blocks the PR instead of trusting the coding agent's self-attestation. Optional:
66+
* absent (older callers, tests) preserves the pre-#8807 flow byte-identically. Loosely typed at this
67+
* public boundary like runSlopAssessment above; the real shape is TargetRepoVerificationResult. */
68+
verifyTargetRepo?: () => Promise<{ status: string } & Record<string, unknown>>;
6369
driver: CodingAgentDriver;
6470
runSlopAssessment: (input: unknown) => unknown;
6571
appendAttemptLogEvent: (event: unknown) => void;
@@ -83,6 +89,7 @@ export type AttemptDeps = {
8389

8490
export type AttemptResult =
8591
| { outcome: "abandon"; loopResult: IterateLoopResult }
92+
| { outcome: "verification_failed"; verification: unknown; loopResult: IterateLoopResult }
8693
| { outcome: "stale"; reason: FreshnessAbortReason; loopResult: IterateLoopResult }
8794
| { outcome: "blocked"; decision: HarnessSubmissionDecision; loopResult: IterateLoopResult }
8895
| { outcome: "governed"; decision: GovernorDecision; loopResult: IterateLoopResult }
@@ -213,6 +220,18 @@ export async function runMinerAttempt(input: AttemptInput, deps: AttemptDeps): P
213220
}
214221
}
215222

223+
// #8807: the independent quality gate — the target repo's own commands against the worktree. Placed
224+
// BEFORE the freshness read so a failing build never spends GitHub API budget. A "skipped" or "passed"
225+
// result proceeds; only a real command failure blocks. Deliberately NOT re-entering the iterate loop in
226+
// this change: the loop's internal self-review iterations already ran, and never-submit-known-bad is the
227+
// trust win — feeding the failure back as loop input is the tracked follow-up on the issue.
228+
if (typeof deps.verifyTargetRepo === "function") {
229+
const verification = await deps.verifyTargetRepo();
230+
if (verification.status === "failed") {
231+
return { outcome: "verification_failed", verification, loopResult };
232+
}
233+
}
234+
216235
const freshness = await checkSubmissionFreshness(
217236
{ repoFullName: input.loopInput.repoFullName, issueNumber: input.issueNumber, minerLogin: input.minerLogin },
218237
{ claimLedger: deps.claimLedger, fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, eventLedger: deps.eventLedger },
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Target-repo verification gate (#8807): run the TARGET repository's own detected test/lint/build commands
2+
// against the attempt's worktree BEFORE a PR opens — the independent check the audit found missing: the
3+
// only verification module that existed (engine lint-guard.ts) is hardcoded to loopover's own monorepo
4+
// commands and never passed in production, and coding-task-spec's validation guidance only TELLS the agent
5+
// which commands to run, trusting its self-attestation. A coding agent that skips or fakes its own test run
6+
// previously produced a PR that passed every AMS-side gate and still broke the target repo's build.
7+
//
8+
// Commands come from stack-detection.js's already-inferred RepoStackResult (the same source the agent's own
9+
// guidance renders), run in test → lint → build order (highest signal first), stop at the first failure,
10+
// with a per-command timeout and a bounded output tail (the postmortem detail, never an unbounded dump).
11+
// An UNDETECTED stack or a stack with no inferred commands SKIPS (recorded, never a failure): this gate can
12+
// only ever be as smart as detection, and refusing to submit because detection came up empty would block
13+
// legitimate work on repos with unconventional tooling.
14+
import { spawn as nodeSpawn } from "node:child_process";
15+
import type { RepoStackResult } from "./stack-detection.js";
16+
17+
/** The slice of ChildProcess the tree-kill needs — narrow so tests can drive both arms with plain fakes. */
18+
export type KillableChild = { pid?: number | undefined; kill: (signal: NodeJS.Signals) => boolean };
19+
20+
export type TargetRepoVerificationSpawn = (
21+
command: string,
22+
options: { cwd: string; timeoutMs: number },
23+
) => Promise<{ code: number | null; output: string }>;
24+
25+
export type TargetRepoVerificationCheck = {
26+
kind: "test" | "lint" | "build";
27+
command: string;
28+
ok: boolean;
29+
exitCode: number | null;
30+
outputTail: string;
31+
};
32+
33+
export type TargetRepoVerificationResult =
34+
| { status: "passed"; checks: TargetRepoVerificationCheck[] }
35+
| { status: "failed"; checks: TargetRepoVerificationCheck[]; firstFailure: TargetRepoVerificationCheck }
36+
| { status: "skipped"; reason: "stack_undetected" | "no_commands_detected" | "disabled" };
37+
38+
/** Per-command wall-clock bound. A target repo's test suite legitimately runs minutes; 10 is the ceiling
39+
* before the gate itself becomes the attempt's bottleneck — a suite slower than this is skipped territory
40+
* for a future per-repo override, not something to silently wait out. */
41+
export const DEFAULT_VERIFICATION_TIMEOUT_MS = 10 * 60 * 1000;
42+
/** Postmortem detail bound — enough tail to show the failing assertion, never an unbounded log dump. */
43+
export const VERIFICATION_OUTPUT_TAIL_CHARS = 4000;
44+
45+
/** Post-timeout grace before giving up on the `close` event: a killed process group's pipes close nearly
46+
* instantly, so this only fires when something double-forked out of the group and kept the pipes open —
47+
* the gate resolves as failed rather than hanging on that orphan. */
48+
export const VERIFICATION_KILL_SETTLE_MS = 5000;
49+
50+
/** Kill the command's whole detached process group via the NEGATIVE pid: a test command is routinely a tree
51+
* (`npm test` → node → workers), and killing only the shell leaves grandchildren holding the stdio pipes —
52+
* the `close` event then waits on THEM, stalling the gate far past its own timeout (observed as the 30s
53+
* hang on Linux CI). Falls back to the plain single-process kill when the group kill isn't possible
54+
* (no pid, or the group is already gone and the signal throws). */
55+
export function killVerificationProcessTree(child: KillableChild, killGroup: (pid: number, signal: NodeJS.Signals) => void = (pid, signal) => process.kill(-pid, signal)): void {
56+
try {
57+
if (typeof child.pid !== "number") throw new Error("child has no pid");
58+
killGroup(child.pid, "SIGKILL");
59+
} catch {
60+
child.kill("SIGKILL");
61+
}
62+
}
63+
64+
/** Default spawn: shell-executed (detected commands are shell strings like "npm test" / "ruff check ."),
65+
* merged stdout+stderr, killed at the timeout (a killed/timed-out command reports code null → treated as
66+
* failure upstream). `internals` exists ONLY for tests to reach the timeout/settle arms deterministically;
67+
* production callers always take the defaults. */
68+
export function runShellCommandWithTreeKill(
69+
command: string,
70+
options: { cwd: string; timeoutMs: number },
71+
internals: { killTree?: (child: KillableChild) => void; settleMs?: number } = {},
72+
): Promise<{ code: number | null; output: string }> {
73+
const killTree = internals.killTree ?? killVerificationProcessTree;
74+
const settleMs = internals.settleMs ?? VERIFICATION_KILL_SETTLE_MS;
75+
return new Promise((resolve) => {
76+
// detached: its own process group, so the timeout can kill the entire tree, not just the shell.
77+
const child = nodeSpawn(command, { cwd: options.cwd, shell: true, stdio: ["ignore", "pipe", "pipe"], detached: true });
78+
let output = "";
79+
let settled = false;
80+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
81+
const finish = (result: { code: number | null; output: string }) => {
82+
if (settled) return;
83+
settled = true;
84+
clearTimeout(timer);
85+
if (settleTimer !== undefined) clearTimeout(settleTimer);
86+
resolve(result);
87+
};
88+
const capture = (chunk: Buffer) => {
89+
output = (output + chunk.toString()).slice(-VERIFICATION_OUTPUT_TAIL_CHARS * 4);
90+
};
91+
child.stdout?.on("data", capture);
92+
child.stderr?.on("data", capture);
93+
const timer = setTimeout(() => {
94+
killTree(child);
95+
// Bounded settle: if some orphan still holds the pipes open after the group kill, resolve as a
96+
// timeout failure anyway — the verification gate must never outlive its own per-command bound.
97+
settleTimer = setTimeout(() => {
98+
finish({ code: null, output: `${output}\n[verification timeout after ${options.timeoutMs}ms — process tree killed]` });
99+
}, settleMs);
100+
}, options.timeoutMs);
101+
child.on("error", (error) => {
102+
finish({ code: null, output: `${output}\n${String(error)}` });
103+
});
104+
child.on("close", (code) => {
105+
finish({ code, output });
106+
});
107+
});
108+
}
109+
110+
export const defaultVerificationSpawn: TargetRepoVerificationSpawn = (command, options) => runShellCommandWithTreeKill(command, options);
111+
112+
export async function runTargetRepoVerification(options: {
113+
worktreeDir: string;
114+
stack: RepoStackResult;
115+
spawn?: TargetRepoVerificationSpawn;
116+
timeoutMsPerCommand?: number;
117+
}): Promise<TargetRepoVerificationResult> {
118+
const stack = options.stack;
119+
if (stack.detected !== true) return { status: "skipped", reason: "stack_undetected" };
120+
const commands: Array<{ kind: TargetRepoVerificationCheck["kind"]; command: string | null }> = [
121+
{ kind: "test", command: stack.testCommand },
122+
{ kind: "lint", command: stack.lintCommand },
123+
{ kind: "build", command: stack.buildCommand },
124+
];
125+
const runnable = commands.filter((entry): entry is { kind: TargetRepoVerificationCheck["kind"]; command: string } => entry.command !== null);
126+
if (runnable.length === 0) return { status: "skipped", reason: "no_commands_detected" };
127+
128+
const spawn = options.spawn ?? defaultVerificationSpawn;
129+
const timeoutMs = options.timeoutMsPerCommand ?? DEFAULT_VERIFICATION_TIMEOUT_MS;
130+
const checks: TargetRepoVerificationCheck[] = [];
131+
for (const { kind, command } of runnable) {
132+
const { code, output } = await spawn(command, { cwd: options.worktreeDir, timeoutMs });
133+
const check: TargetRepoVerificationCheck = {
134+
kind,
135+
command,
136+
ok: code === 0,
137+
exitCode: code,
138+
outputTail: output.slice(-VERIFICATION_OUTPUT_TAIL_CHARS),
139+
};
140+
checks.push(check);
141+
// Stop at the first failure: the remaining commands' results would only pile noise onto an attempt that
142+
// is already not submitting, and a broken build often cascades into misleading downstream failures.
143+
if (!check.ok) return { status: "failed", checks, firstFailure: check };
144+
}
145+
return { status: "passed", checks };
146+
}

test/unit/miner-attempt-cli.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2670,3 +2670,77 @@ describe("resolveAttemptHouseRulesConfig (#8806)", () => {
26702670
expect(close).toHaveBeenCalled();
26712671
});
26722672
});
2673+
2674+
describe("target-repo verification wiring (#8807)", () => {
2675+
it("binds verifyTargetRepo into the runner deps (worktree-scoped thunk over the injected verifier)", async () => {
2676+
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
2677+
vi.spyOn(console, "log").mockImplementation(() => undefined);
2678+
const runMinerAttemptSpy = vi.fn(async (_input: unknown, deps: { verifyTargetRepo?: () => Promise<unknown> }) => {
2679+
// The thunk exists and resolves through the injected verifier when invoked.
2680+
expect(typeof deps.verifyTargetRepo).toBe("function");
2681+
const verification = await deps.verifyTargetRepo!();
2682+
expect(verification).toEqual({ status: "skipped", reason: "stack_undetected" });
2683+
return { outcome: "abandon", loopResult: { outcome: "abandon", iterations: [], finalMeterTotals: { tokens: 0 } } };
2684+
});
2685+
const runVerifier = vi.fn(async (opts: { worktreeDir: string }) => {
2686+
expect(opts.worktreeDir).toBeTruthy(); // bound to THIS attempt's worktree
2687+
return { status: "skipped" as const, reason: "stack_undetected" as const };
2688+
});
2689+
2690+
await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
2691+
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
2692+
openWorktreeAllocator: () => allocator,
2693+
openClaimLedger: () => claimLedger,
2694+
initEventLedger: () => eventLedger,
2695+
initAttemptLog: () => attemptLog,
2696+
initGovernorLedger: () => governorLedger,
2697+
...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy, runTargetRepoVerification: runVerifier }),
2698+
});
2699+
2700+
expect(runMinerAttemptSpy).toHaveBeenCalled();
2701+
expect(runVerifier).toHaveBeenCalled();
2702+
});
2703+
2704+
it("without an injected verifier the thunk runs the REAL verification (default arm) — an unmarked temp worktree skips", async () => {
2705+
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
2706+
vi.spyOn(console, "log").mockImplementation(() => undefined);
2707+
const runMinerAttemptSpy = vi.fn(async (_input: unknown, deps: { verifyTargetRepo?: () => Promise<{ status: string }> }) => {
2708+
const verification = await deps.verifyTargetRepo!();
2709+
expect(verification.status).toBe("skipped"); // no stack markers in the fixture worktree
2710+
return { outcome: "abandon", loopResult: { outcome: "abandon", iterations: [], finalMeterTotals: { tokens: 0 } } };
2711+
});
2712+
2713+
await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
2714+
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
2715+
openWorktreeAllocator: () => allocator,
2716+
openClaimLedger: () => claimLedger,
2717+
initEventLedger: () => eventLedger,
2718+
initAttemptLog: () => attemptLog,
2719+
initGovernorLedger: () => governorLedger,
2720+
...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }),
2721+
});
2722+
2723+
expect(runMinerAttemptSpy).toHaveBeenCalled();
2724+
});
2725+
2726+
it("MINER_SKIP_TARGET_REPO_VERIFICATION omits the thunk entirely — the documented escape hatch", async () => {
2727+
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
2728+
vi.spyOn(console, "log").mockImplementation(() => undefined);
2729+
const runMinerAttemptSpy = vi.fn(async (_input: unknown, deps: { verifyTargetRepo?: unknown }) => {
2730+
expect(deps.verifyTargetRepo).toBeUndefined();
2731+
return { outcome: "abandon", loopResult: { outcome: "abandon", iterations: [], finalMeterTotals: { tokens: 0 } } };
2732+
});
2733+
2734+
await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
2735+
env: { MINER_CODING_AGENT_PROVIDER: "noop", MINER_SKIP_TARGET_REPO_VERIFICATION: "1" },
2736+
openWorktreeAllocator: () => allocator,
2737+
openClaimLedger: () => claimLedger,
2738+
initEventLedger: () => eventLedger,
2739+
initAttemptLog: () => attemptLog,
2740+
initGovernorLedger: () => governorLedger,
2741+
...readyPipelineOptions({ runMinerAttempt: runMinerAttemptSpy }),
2742+
});
2743+
2744+
expect(runMinerAttemptSpy).toHaveBeenCalled();
2745+
});
2746+
});

0 commit comments

Comments
 (0)