postrun: thread AgentRunContext through the retry loop instead of repackaging (#652)

* postrun: thread AgentRunContext through the retry loop instead of repackaging

drop the per-gate plumbing in `runPostRunRetryLoop`: the loop now receives
`ctx: AgentRunContext` whole and reads `ctx.stopScript` + `ctx.toolState.*`
directly. `getUnsubmittedReview` becomes a pure utility in postRun.ts
instead of a closure shipped over `AgentRunContext`. `AgentRunContext`
loses 4 fields that duplicated `toolState` (`summaryFilePath`,
`summarySeed`, `learningsFilePath`, `getUnsubmittedReview`) and gains
`toolState: ToolState`. both harness call sites collapse from 11 lines to
7; main.ts deletes the inline closure.

`ToolState` and friends move from `action/mcp/server.ts` to
`action/toolState.ts` so non-MCP code (agents, post-run loop) stops
importing run-state types from the MCP server module.

no behavior change. 503/503 tests green.

* toolState: relocate `CommentableLines` to break dep cycle with mcp/review

`action/toolState.ts` was importing `CommentableLines` from
`mcp/review.ts`, which pulled the entire MCP server compile graph (24
files) into any consumer of `ToolState` — including `cf-worker-indexing`
via the `pullfrog/internal` re-export chain through `utils/log.ts` →
`agents/shared.ts` → `toolState.ts`. that exposed a pre-existing TS
error in `mcp/issueEvents.ts` (octokit types resolve differently under
cf-worker's `moduleResolution: bundler`).

move `CommentableLines` (a small `{ RIGHT: Set<number>; LEFT: Set<number> }`
state-shape type) to `toolState.ts` where it's used; re-export from
`mcp/review.ts` for back-compat with test and call-site imports. cuts
cf-worker's mcp/ compile inclusion from 24 files back to 0.

* postRun: drop mock-heavy retry-loop tests; keep pure gate predicate

`runPostRunRetryLoop` and `executeStopHook` were covered by ~560 lines
of mock-heavy regression-gate tests that stubbed `spawn` / `getGitStatus`
and fabricated `AgentRunContext` to drive orchestration paths. per
AGENTS.md ("prefer no test over a mock-heavy test that only catches the
most obvious form of regression") and the empirical track record — the
one real production failure of this code path (#646) was a missing npm
release, not a logic bug a unit test could catch — the value-to-ceremony
ratio is poor. delete them.

keep only the pure predicate: `getUnsubmittedReview(toolState)` is a
decision function whose four input conditions have user-visible
consequences when wrong. 5 assertions, no mocks, no ctx fabrication.

488 tests still pass.

* toolState: import PrepResult from prep/types.ts, not the barrel

same dep-cycle class as the previous CommentableLines fix. importing
PrepResult from prep/index.ts pulled prep/installNodeDependencies.ts
into the Next.js production build's typecheck graph (via
pullfrog/internal → utils/log.ts → agents/shared.ts → toolState.ts →
prep/index.ts → installNodeDependencies.ts), and Next.js's stricter
NODE_ENV-required ProcessEnv shape rejected an existing
`env: { PATH: ... }` literal.

prep/types.ts is a leaf module with zero imports — re-routing the type
import severs the chain. Vercel preview deploy goes from Error → Ready;
preview-sync stops racing the deploy.
This commit is contained in:
Colin McDonnell
2026-05-11 18:47:08 +00:00
committed by pullfrog[bot]
parent dee13b160f
commit 8e36f76cfa
14 changed files with 299 additions and 837 deletions
+47 -48
View File
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises";
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
import type { ToolState } from "../toolState.ts";
import { log } from "../utils/cli.ts";
import {
SPAWN_ACTIVITY_TIMEOUT_CODE,
@@ -9,6 +10,7 @@ import {
} from "../utils/subprocess.ts";
import {
type AgentResult,
type AgentRunContext,
type AgentUsage,
buildCommitPrompt,
getGitStatus,
@@ -19,6 +21,23 @@ import {
type StopHookFailure,
} from "./shared.ts";
/**
* derive "agent picked a review mode but never produced visible output" from
* the literal facts on `toolState`. returns the selected mode when the gate
* should fire, `null` otherwise — pure read, no side effects, safe to invoke
* after every agent attempt.
*
* the gate is anchored to `hadProgressComment` so silent runs (non-issue
* events, dispatcher skipped seeding) don't fire a nudge there's no UI for.
*/
export function getUnsubmittedReview(toolState: ToolState): "Review" | "IncrementalReview" | null {
const mode = toolState.selectedMode;
if (mode !== "Review" && mode !== "IncrementalReview") return null;
if (toolState.review || toolState.finalSummaryWritten) return null;
if (!toolState.hadProgressComment) return null;
return mode;
}
/**
* hook output can flow into two size-sensitive places: the LLM resume prompt
* (context window) and AgentResult.error (surfaced in GitHub comments capped
@@ -144,37 +163,34 @@ export function buildUnsubmittedReviewPrompt(mode: "Review" | "IncrementalReview
/**
* check the post-run gates: did the stop hook pass, is the working tree
* clean, and (when applicable) did the agent touch the rolling PR summary
* snapshot? returns everything that still needs nudging so the caller can
* render a single combined resume prompt.
* snapshot or produce review output? returns everything that still needs
* nudging so the caller can render a single combined resume prompt.
*
* the summary-stale check is skipped when `summaryFilePath` / `summarySeed`
* are not provided; this is the common case (non-PR runs, runs where the
* dispatcher didn't request snapshot generation, runs where the seed step
* failed). loop callers also pass these as undefined after the agent has
* already been nudged once, to avoid burning the retry budget on a soft
* non-blocking gate.
* reads run state directly off `ctx.toolState` so each invocation sees the
* latest mutations from MCP tool calls. `skipSummaryStale` lets the loop
* suppress the summary-stale check after the one-shot nudge has been
* delivered (re-firing it would burn the retry budget on a soft gate the
* agent has already decided not to act on).
*/
export async function collectPostRunIssues(params: {
stopScript: string | null | undefined;
summaryFilePath?: string | undefined;
summarySeed?: string | undefined;
getUnsubmittedReview?: (() => "Review" | "IncrementalReview" | null) | undefined;
}): Promise<PostRunIssues> {
export async function collectPostRunIssues(
ctx: AgentRunContext,
options: { skipSummaryStale?: boolean } = {}
): Promise<PostRunIssues> {
const issues: PostRunIssues = {};
if (params.stopScript) {
const failure = await executeStopHook(params.stopScript);
if (ctx.stopScript) {
const failure = await executeStopHook(ctx.stopScript);
if (failure) issues.stopHook = failure;
}
const status = getGitStatus();
if (status) issues.dirtyTree = status;
if (params.summaryFilePath && params.summarySeed !== undefined) {
const stale = await isSummaryUnchanged(params.summaryFilePath, params.summarySeed);
if (stale) issues.summaryStale = { filePath: params.summaryFilePath };
}
if (params.getUnsubmittedReview) {
const mode = params.getUnsubmittedReview();
if (mode) issues.unsubmittedReview = mode;
const summaryFilePath = ctx.toolState.summaryFilePath;
const summarySeed = ctx.toolState.summarySeed;
if (!options.skipSummaryStale && summaryFilePath && summarySeed !== undefined) {
const stale = await isSummaryUnchanged(summaryFilePath, summarySeed);
if (stale) issues.summaryStale = { filePath: summaryFilePath };
}
const unsubmittedMode = getUnsubmittedReview(ctx.toolState);
if (unsubmittedMode) issues.unsubmittedReview = unsubmittedMode;
return issues;
}
@@ -242,19 +258,9 @@ export function buildLearningsReflectionPrompt(filePath: string): string {
* behavior: they're logged but don't fail the run.
*/
export async function runPostRunRetryLoop<R extends AgentResult>(params: {
ctx: AgentRunContext;
initialResult: R;
initialUsage: AgentUsage | undefined;
stopScript: string | null | undefined;
/** absolute path to the seeded PR summary file. when set together with
* `summarySeed`, the loop checks after each agent attempt whether the
* file has been edited; if not, it nudges the agent ONCE via a resume
* turn (subsequent iterations skip the check so we don't keep burning
* retries on a soft gate when the agent has decided no edit is warranted). */
summaryFilePath?: string | undefined;
/** exact bytes of the seeded summary file used for the unchanged-check. */
summarySeed?: string | undefined;
/** see {@link AgentRunContext.getUnsubmittedReview}. */
getUnsubmittedReview?: (() => "Review" | "IncrementalReview" | null) | undefined;
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
canResume?: ((result: R) => boolean) | undefined;
reflectionPrompt?: string | undefined;
@@ -264,20 +270,16 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
let finalIssues: PostRunIssues = {};
let gateResumeCount = 0;
let pendingReflection = params.reflectionPrompt;
// nudge for an untouched summary file fires AT MOST ONCE per run. after
// we've delivered the prompt, subsequent gate checks pass undefined so
// the loop doesn't keep flagging the same condition — the agent may have
// legitimately decided no edit is warranted, and re-prompting would
// burn the retry budget without adding signal.
// nudge for an untouched summary file fires AT MOST ONCE per run. once
// delivered, subsequent collectPostRunIssues calls skip the check — the
// agent may have legitimately decided no edit is warranted, and
// re-prompting would burn the retry budget without adding signal.
let summaryStaleNudged = false;
while (gateResumeCount < MAX_POST_RUN_RETRIES) {
if (!result.success) break;
const issues = await collectPostRunIssues({
stopScript: params.stopScript,
summaryFilePath: summaryStaleNudged ? undefined : params.summaryFilePath,
summarySeed: summaryStaleNudged ? undefined : params.summarySeed,
getUnsubmittedReview: params.getUnsubmittedReview,
const issues = await collectPostRunIssues(params.ctx, {
skipSummaryStale: summaryStaleNudged,
});
if (issues.summaryStale) summaryStaleNudged = true;
finalIssues = issues;
@@ -367,10 +369,7 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
// NOT re-checked here: we already delivered the one-shot nudge, and
// a still-unchanged file at this point is the agent's deliberate
// choice.
finalIssues = await collectPostRunIssues({
stopScript: params.stopScript,
getUnsubmittedReview: params.getUnsubmittedReview,
});
finalIssues = await collectPostRunIssues(params.ctx, { skipSummaryStale: true });
}
if (result.success && finalIssues.stopHook) {