Implements the fix-or-disagree agent loop behind pr-respond: batched one-run-per-cycle over actionable threads (filtering resolved/outdated/ self/bot), with prompt-injection delimiting of untrusted comment bodies, viewer-authenticated anti-spoof markers, a pre-push secret scan, fast- forward-only push (no force-push path) with non-ff abort+re-batch, reply+ resolve, commit-last thread-state persistence with marker+SHA crash recovery (R15), and an iteration cap on responseRounds (R8). GitHub/git/ agent ops injected; engine stays dashboard-import-free. Adds GraphQL getPrReviewThreadsDetailed + getViewerLogin to the client. 23 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
7.0 KiB
TypeScript
177 lines
7.0 KiB
TypeScript
// Engine-side construction of the git + agent operations the U5 review-response
|
|
// run needs. These close over the engine-owned store/settings/worktree and the
|
|
// session helpers — the CLI composition layer supplies only the GitHub-client
|
|
// callbacks + a project-root resolver (it never holds these engine concerns).
|
|
//
|
|
// Kept separate from `pr-response-run.ts` (the pure orchestration) so the
|
|
// orchestration stays trivially unit-testable with fakes and these I/O builders
|
|
// can be excluded from those tests.
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import type { PrEntity, Settings, TaskStore } from "@fusion/core";
|
|
import { resolveAgentPrompt } from "@fusion/core";
|
|
import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js";
|
|
import { promptWithFallback } from "./pi.js";
|
|
import { withRateLimitRetry } from "./rate-limit-retry.js";
|
|
import { checkSessionError } from "./usage-limit-detector.js";
|
|
import {
|
|
buildResponseSystemPrompt,
|
|
type PrAgentRunResult,
|
|
type PrPushResult,
|
|
type PrThreadVerdict,
|
|
} from "./pr-response-run.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
async function git(args: string[], cwd: string): Promise<string> {
|
|
const { stdout } = await execFileAsync("git", args, {
|
|
cwd,
|
|
encoding: "utf-8",
|
|
timeout: 120_000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
return stdout.trim();
|
|
}
|
|
|
|
/**
|
|
* The per-thread verdict marker the agent emits. The agent run is instructed to
|
|
* end with one `PR_THREAD:` line per thread; we parse them into structured
|
|
* verdicts. Fail-safe: a thread with no parseable verdict is treated as a
|
|
* disagreement (never an unrequested code change, never a silent fix).
|
|
*/
|
|
const VERDICT_LINE_RE = /^PR_THREAD:\s*(\S+)\s+(fix|disagree)\b\s*(.*)$/i;
|
|
|
|
export function parseAgentVerdicts(text: string, threadIds: string[]): PrThreadVerdict[] {
|
|
const byThread = new Map<string, PrThreadVerdict>();
|
|
for (const line of (text ?? "").split(/\r?\n/)) {
|
|
const m = VERDICT_LINE_RE.exec(line.trim());
|
|
if (!m) continue;
|
|
const [, threadId, decisionRaw, reply] = m;
|
|
const decision = decisionRaw.toLowerCase() === "fix" ? "fix" : "disagree";
|
|
byThread.set(threadId, { threadId, decision, reply: reply.trim() || "(no reasoning provided)" });
|
|
}
|
|
// Fail-safe default for any thread the agent did not emit a verdict for.
|
|
const verdicts: PrThreadVerdict[] = [];
|
|
for (const id of threadIds) {
|
|
verdicts.push(
|
|
byThread.get(id) ?? {
|
|
threadId: id,
|
|
decision: "disagree",
|
|
reply: "No actionable change was identified for this thread.",
|
|
},
|
|
);
|
|
}
|
|
return verdicts;
|
|
}
|
|
|
|
/** Build the engine-owned mutating agent runner for the response run. */
|
|
export function makePrResponseAgentRunner(
|
|
store: TaskStore,
|
|
settings: Settings,
|
|
taskId: string,
|
|
cwd: string,
|
|
): (input: {
|
|
prompt: string;
|
|
systemPrompt: string;
|
|
signal?: AbortSignal;
|
|
threads: Array<{ id: string }>;
|
|
}) => Promise<PrAgentRunResult> {
|
|
return async ({ prompt, systemPrompt, signal, threads }) => {
|
|
const model = resolveMergerSessionModel(settings);
|
|
let captured = "";
|
|
// Append the strict verdict-output contract to the (untrusted-declaring)
|
|
// system prompt so the agent emits parseable per-thread decisions.
|
|
const fullSystem = [
|
|
systemPrompt,
|
|
"",
|
|
"OUTPUT CONTRACT:",
|
|
" After making any code changes and committing them, end your turn with",
|
|
" exactly one line per thread of the form:",
|
|
" PR_THREAD: <threadId> fix <one-line summary of the change>",
|
|
" PR_THREAD: <threadId> disagree <one-line reasoning>",
|
|
].join("\n");
|
|
const { session } = await createResolvedAgentSession({
|
|
sessionPurpose: "merger",
|
|
cwd,
|
|
systemPrompt: fullSystem,
|
|
tools: "coding",
|
|
onText: (delta: string) => {
|
|
captured += delta;
|
|
},
|
|
defaultProvider: model.provider,
|
|
defaultModelId: model.modelId,
|
|
fallbackProvider: settings.fallbackProvider,
|
|
fallbackModelId: settings.fallbackModelId,
|
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
|
settings,
|
|
taskId,
|
|
});
|
|
try {
|
|
await withRateLimitRetry(async () => {
|
|
await promptWithFallback(session, prompt);
|
|
checkSessionError(session);
|
|
}, { signal });
|
|
} finally {
|
|
session.dispose();
|
|
}
|
|
return { verdicts: parseAgentVerdicts(captured, threads.map((t) => t.id)) };
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the git ops (content read, worktree head, fast-forward push) bound to a
|
|
* worktree `cwd`. The push is fast-forward-ONLY — there is no force-push path.
|
|
*/
|
|
export function makePrResponseGitOps(getCwd: (entity: PrEntity) => string): {
|
|
getChangedContent: (entity: PrEntity) => Promise<Array<{ path: string; content: string }>>;
|
|
getWorktreeHeadOid: (entity: PrEntity) => Promise<string | null>;
|
|
fetchAndFastForwardPush: (entity: PrEntity) => Promise<PrPushResult>;
|
|
} {
|
|
return {
|
|
getChangedContent: async (entity) => {
|
|
const cwd = getCwd(entity);
|
|
// Diff the local branch tip against its upstream to read what would be
|
|
// pushed. `@{u}...HEAD` enumerates the commits unique to HEAD.
|
|
const range = `origin/${entity.headBranch}..HEAD`;
|
|
const names = (await git(["diff", "--name-only", range], cwd).catch(() => "")).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
const out: Array<{ path: string; content: string }> = [];
|
|
for (const path of names) {
|
|
const content = await git(["show", `HEAD:${path}`], cwd).catch(() => "");
|
|
out.push({ path, content });
|
|
}
|
|
return out;
|
|
},
|
|
getWorktreeHeadOid: async (entity) => {
|
|
const cwd = getCwd(entity);
|
|
return (await git(["rev-parse", "HEAD"], cwd).catch(() => "")) || null;
|
|
},
|
|
fetchAndFastForwardPush: async (entity) => {
|
|
const cwd = getCwd(entity);
|
|
const branch = entity.headBranch;
|
|
await git(["fetch", "origin", branch], cwd).catch(() => undefined);
|
|
// Local must be ahead-of or equal-to origin (fast-forward). If origin has
|
|
// commits we don't have (human pushed in between) → non-ff, abort.
|
|
const remoteRef = `origin/${branch}`;
|
|
const localHead = await git(["rev-parse", "HEAD"], cwd).catch(() => "");
|
|
const remoteHead = await git(["rev-parse", remoteRef], cwd).catch(() => "");
|
|
if (!localHead) return { status: "no-op" };
|
|
if (remoteHead && remoteHead === localHead) return { status: "no-op" };
|
|
if (remoteHead) {
|
|
// Is remoteHead an ancestor of localHead? If not, the push is non-ff.
|
|
const isAncestor = await git(["merge-base", "--is-ancestor", remoteHead, localHead], cwd)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (!isAncestor) return { status: "non-ff" };
|
|
}
|
|
// Plain (non-force) push. `git push` fails on a non-ff; we already guarded.
|
|
await git(["push", "origin", `HEAD:${branch}`], cwd);
|
|
return { status: "pushed", sha: localHead };
|
|
},
|
|
};
|
|
}
|
|
|
|
// Re-export so the CLI factory can reference the system-prompt builder without a
|
|
// second import path.
|
|
export { buildResponseSystemPrompt, resolveAgentPrompt };
|