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:
committed by
pullfrog[bot]
parent
dee13b160f
commit
8e36f76cfa
+47
-48
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user