e4e93ea6d3
* PR summary as agent-edited tmpfile snapshot Replaces the comment-based PR summary path (and the in-progress update_pr_summary tool from #534) with a snapshot file the agent edits in place during Review / IncrementalReview / pr-summary Task runs. The server seeds the tmpfile with the previous snapshot (incremental) or a stable scaffold (first run), exposes the path via select_mode, and reads it back at end-of-run to persist to WorkflowRun.summarySnapshot and (when the prSummaryComment toggle is on) splice into the PR description body. Why a tmpfile rather than a tool call: incremental snapshot edits are output-token-cheap when the agent uses native file-editing tools, and range-diff cleanly across runs because section headings are stable. The agent never has to regurgitate the full snapshot to update it. Gating: snapshot generation is opt-in via either prSummaryComment="enabled" (splice into PR body) or prReReview="enabled" (snapshot feeds future incremental review runs as context). Users who disable both pay nothing end-to-end — no seeding, DB write, or body splice. Behavior changes: - Drop the Summarize mode and the Summary comment type entirely; the rolling summary is no longer a separate run shape. - pull_request_synchronize with re-review off and summary on still dispatches a silent pr-summary Task, but it edits the snapshot file instead of posting a fresh comment. - /api/repo/.../pr/.../summary-comment now returns { snapshot: string | null } from the DB instead of fetching a comment via GraphQL. URL kept stable so deployed older actions degrade gracefully. - summaryCommentNodeId is retained on WorkflowRun for legacy data and a future backfill of pre-snapshot comment-based summaries. Supersedes #534. The commit-tool/sub-agent direction in that PR is abandoned in favor of this file-based shape. * address review pass #1: synchronize fallback, splice idempotency, docs * address review pass #2: in-flight skip should not race summary fallback * address review pass #3: signal-handler flush, doc clarifications * address review pass #4: in-flight persist promise + bounded body-splice timeout * address review pass #5: defensive catch on persist worker, doc nit * add summary-stale post-run gate When generateSummary is set, we capture the bytes of the seeded snapshot file and pass them to the agent's post-run loop alongside the file path. After each agent attempt, the loop diffs the current file against the seed; if they're byte-identical the agent never touched it, and we nudge once via a resume turn (similar to the dirty-tree gate, but soft and fire-once so smaller models that legitimately decide no edit is warranted don't burn the retry budget). Mostly defends against forgetful smaller models on the Review path — their mode prompt asks them to edit the snapshot file, but the multi-step instruction can fall through when the diff is large. * trigger: retry vercel preview build * fix(action): drop unused re-export that pulled node:fs/promises into next bundle action/internal/index.ts was re-exporting DEFAULT_PR_SUMMARY_INSTRUCTIONS from action/utils/prSummary.ts, but nothing in the next.js app imports it. prSummary.ts uses node:fs/promises, and pullfrog/internal is aliased into the next bundle by next.config.ts, which made turbopack try to resolve node:fs/promises in client chunks and fail with: the chunking context (unknown) does not support external modules (request: node:fs/promises) drop the re-export — selectMode.ts (the only real consumer) already imports it directly from action/utils/prSummary.ts. * firewall PR summary snapshot from user instructions; resurrect rich format for Review The agent-internal snapshot (the markdown file the agent edits in place across runs) is exclusively durable context for future agent runs — user-supplied summarization instructions warp it and degrade that context. Drop the prSummaryCommentInstructions read path end-to-end: - handleWebhook: stop reading prSummaryCommentInstructions, stop passing prSummaryInstructions through dispatch options - action payload + ToolState + selectMode addendum: drop the instructions appendix; the snapshot prompt is fixed, not user-shaped - TriggersSettings: drop the InstructionsEditor for prSummaryCommentInstructions - prSummary.ts: reframe DEFAULT_PR_SUMMARY_INSTRUCTIONS as agent-targeted (durable context, not human-facing prose) Prisma columns (prSummaryComment, prSummaryCommentInstructions) and the matching zod schema entry stay for graceful retreat. Separately, resurrect PR_SUMMARY_FORMAT (deleted along with the Summarize mode in the original PR) and wire it into Review mode only. Initial PR reviews now include a structured summary section in the review body using the rich format (TL;DR, key changes, ## sections with before/after, file-link trails). IncrementalReview keeps its existing terser bullet-list shape since re-review bodies are deltas, not introductions. The user-facing review summary and the agent-internal snapshot are deliberately separate artifacts with separate prompts and zero shared content. * address review comments: prompt self-consistency + stale-doc cleanup PR 568 self-review (4232488109) flagged a self-contradiction the firewall commit introduced and three stale doc references that survived. - action/modes.ts: Review-mode step 2's trivial-PR shortcut said `submit "Reviewed — no issues found." per step 5`, but step 5's rewrite removed exactly that preamble. Aligned both: trivial PRs and no-actionable-issues PRs now produce a body that opens with "No new issues found." followed by the PR summary, so the user gets the headline up front and still sees what was reviewed. - docs/pr-reviews.mdx: dropped the "customize the summary style with Summary instructions in the console" sentence (the editor was removed in the firewall commit). Replaced with a note that the snapshot uses Pullfrog's built-in format and is not user-customizable. - wiki/prompt.md, wiki/modes.md: rewrote the snapshot-prompt entries to reflect the firewall — DEFAULT_PR_SUMMARY_INSTRUCTIONS is the entire prompt, prSummaryCommentInstructions is no longer wired in. * drop orphaned prSummaryCommentInstructions column Prod audit (455 repos): 5 non-null rows on a single account, all containing the literal placeholder text from the InstructionsEditor we removed in the firewall commit. No account has an intentional preference set, so silent-ignore (the keep-for-retreat option) costs us nothing meaningful while leaving an orphan column in the schema. Drop it. - prisma/schema.prisma: remove the column - prisma/migrations/20260506000000_drop_pr_summary_comment_instructions: ALTER TABLE ... DROP COLUMN - utils/schemas/triggers.ts: drop the matching zod entry * drop body splicing; snapshot is internal-only User-visible PR summarization continues to ship in Review and IncrementalReview review bodies (which already render PR_SUMMARY_FORMAT and "Reviewed changes" respectively). The snapshot tmpfile is now purely durable cross-run agent context — seed, edit-in-place, save to DB, feed the next run. Massive simplification: the body splice mechanics, the two-toggle gating matrix, the summaryHandlingCovered race tracking, and the synchronize summary-only Task fallback all go away. Code: - prSummary.ts: drop splice/strip/marker code (`splicePrSummary`, `stripExistingSummaryBlock`, `buildSummaryBlock`, `extractPrSummary`, PULLFROG_SUMMARY_START/END). keep scaffold, instructions, seed/read. - main.ts: rename persistAndPostSummary -> persistSummary; collapse to a single DB PATCH. drop pulls.get/pulls.update, drop AbortSignal timeout, drop in-flight promise machinery, drop prSummaryToBody plumbing. - ToolState: add summarySeed (replaces local var in main.ts so persist can compare). drop prSummaryToBody and summaryPersistInFlight. - persistSummary now compares against the seed and skips the DB write with a warning when unchanged — saving the seed verbatim is either a no-op or persists the placeholder scaffold, neither useful. - postRun.ts: when summary-stale is the only failing gate and the resume turn itself fails, restore the pre-resume successful result and break. symmetric with the existing reflection-failure preservation. summary-stale can no longer flip a successful run to failed. Webhook: - pull_request_opened: generateSummary follows prReReview only (the snapshot has no consumer when re-review is off). - pull_request_synchronize: collapses to "if prReReview enabled, dispatch IncrementalReview". the summaryHandlingCovered flag, the same-SHA/in-flight coordination it was protecting, and the summary-only Task fallback all delete cleanly. UI / config: - drop SummarizePRsTrigger (the toggle gated body splice; with that gone it has no behavior). drop sidebar entry, console import, Text icon import. - drop prSummaryComment from triggers zod schema, prisma schema, preview settings script. Migration: squash the two existing migrations into one timestamped 20260507000000_pr_summary_snapshot covering all three column changes (add summarySnapshot on workflow_runs, drop prSummaryCommentInstructions and prSummaryComment on repos). repo convention is one migration per PR. Action: bump 0.0.203 -> 0.0.205 (payload contract changed: prSummaryToBody removed; main is at 0.0.204). Out-of-diff cleanup: - review.ts:190 + review.test.ts:651 — "Reviewed — no issues found." -> "No new issues found." to match the canonical body in modes.ts. Verified: pnpm typecheck clean, pnpm lint clean, postRun + review tests pass, dev DB reset against production and the squashed migration applied cleanly (summarySnapshot present, prSummaryComment / prSummaryCommentInstructions both gone). * re-orient snapshot toward functional summary; drop prior-review-feedback section Empirical audit on preview-568 PR #5 showed the snapshot IS load-bearing for the orchestrator: lens-dispatch prompts on incremental runs carried forward context from the snapshot's risk register (e.g. "the JSDoc explicitly scopes to code points — do not flag grapheme-cluster issues" on the surrogate-pair fix run, "consistency with native padStart" on the padStart-added run). The orchestrator was reading the snapshot, reasoning about it, and using it to anti-prime / focus subagents — exactly the high-leverage path. My earlier "snapshot is write-only" claim was wrong. The shape, however, was steering it toward review-history-log instead of functional summary. This commit re-orients: - prSummary.ts: replace the four-section scaffold (~580 chars of placeholder italics under "What this PR does / Key changes / Risk / Reviewed in prior runs") with a minimal seed (~150 chars: just a header + a one-line comment about what the file is for). different PRs warrant different organization; forcing a refactor and a feature into the same template is procrustean. minimal seed also makes the unchanged-from-seed gate in persistSummary more sensitive. - selectMode.ts addendum: rewrite around three principles. (1) the snapshot is a FUNCTIONAL summary of what the PR does and the risks it carries, not a chronological review log — commit history can already be reconstructed from list_pull_request_reviews. (2) the orchestrator should USE the snapshot during triage and dispatch — concrete example given of carrying snapshot context into subagent lens prompts. (3) structure is the agent's call; stable headings make snapshots range-diff cleanly when they fit, but riff when they don't. - modes.ts IncrementalReview: drop the "Prior review feedback" checklist from the user-facing review body (step 6b gone, step 7 ELSE IFs cleaned up). It duplicated content that's already covered by the Reviewed-changes bullets and tracked durably in the snapshot for the next agent run; in the user-facing body it was noise. step 3 still fetches prior reviews but its role is now just filtering aggregation in step 5, not rendering. - AGENTS.md: codify "no follow-ups" rule. when an issue is identified during code review, fix it in this PR — PR scope does not constrain quality. follow-up TODOs are forbidden as a substitute for doing the work now. Empirical evidence supporting the re-orientation: - Run 25568912293 (PR#5 incr1, surrogate-pair fix): orchestrator's correctness lens dispatch said "Do NOT flag grapheme-cluster issues — the JSDoc scopes to code points." The grapheme-cluster framing was not in the diff; it was downstream of the snapshot's prior risk-section framing of truncate's contract. Snapshot influencing dispatch. - Run 25569054779 (PR#5 incr2, padStart added): orchestrator's correctness lens dispatch enumerated edge cases including "consistency with native String.prototype.padStart contract" and "fill = multi-code-point string (e.g. emoji)". Both threads carried over from the snapshot's prior truncate code-point-vs-code-unit discussion. Snapshot informing the shape of what was looked for. The cost of maintaining the snapshot (~800 tokens, ~$0.005/run) is trivially affordable when it materially improves orchestrator triage on the 1-5 lenses dispatched per review.
342 lines
16 KiB
TypeScript
342 lines
16 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { type AgentId, formatMcpToolRef } from "../external.ts";
|
|
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import {
|
|
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
|
SPAWN_TIMEOUT_CODE,
|
|
SpawnTimeoutError,
|
|
spawn,
|
|
} from "../utils/subprocess.ts";
|
|
import {
|
|
type AgentResult,
|
|
type AgentUsage,
|
|
buildCommitPrompt,
|
|
getGitStatus,
|
|
hasPostRunIssues,
|
|
MAX_POST_RUN_RETRIES,
|
|
mergeAgentUsage,
|
|
type PostRunIssues,
|
|
type StopHookFailure,
|
|
} from "./shared.ts";
|
|
|
|
/**
|
|
* hook output can flow into two size-sensitive places: the LLM resume prompt
|
|
* (context window) and AgentResult.error (surfaced in GitHub comments capped
|
|
* at 65535 chars). truncate the tail to keep both bounded; the tail is
|
|
* usually the most actionable part of a failing script's output.
|
|
*/
|
|
const MAX_HOOK_OUTPUT_CHARS = 4096;
|
|
|
|
function truncateHookOutput(raw: string): string {
|
|
if (raw.length <= MAX_HOOK_OUTPUT_CHARS) return raw;
|
|
return `...(truncated, showing last ${MAX_HOOK_OUTPUT_CHARS} chars)\n${raw.slice(-MAX_HOOK_OUTPUT_CHARS)}`;
|
|
}
|
|
|
|
/**
|
|
* run the user-configured stop hook.
|
|
*
|
|
* parallel to `executeLifecycleHook` (which soft-fails with a warning), but
|
|
* returns structured output so agent harnesses can feed the failure back into
|
|
* the session as a resume prompt.
|
|
*
|
|
* - non-zero exit → `StopHookFailure`, actionable: the output is fed to the
|
|
* agent so it can fix the underlying issue.
|
|
* - timeout / spawn error → null, treated as passed: we can't usefully ask the
|
|
* agent to fix an infrastructure problem, and retrying would risk infinite
|
|
* loops.
|
|
*/
|
|
export async function executeStopHook(script: string): Promise<StopHookFailure | null> {
|
|
log.info("» executing stop hook...");
|
|
try {
|
|
const result = await spawn({
|
|
cmd: "bash",
|
|
args: ["-c", script],
|
|
env: process.env,
|
|
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
|
|
activityTimeout: 0,
|
|
onStdout: (chunk) => process.stdout.write(chunk),
|
|
onStderr: (chunk) => process.stderr.write(chunk),
|
|
});
|
|
if (result.exitCode === 0) {
|
|
log.info("» stop hook passed");
|
|
return null;
|
|
}
|
|
// include both streams — scripts often emit a benign warning to stderr
|
|
// and the actionable error to stdout (or vice versa), and picking one
|
|
// starves the agent of the diagnostic it needs. stderr-first so stdout
|
|
// (typically longer, where truncation is more likely to bite) keeps its
|
|
// tail — summaries/totals usually live at the end.
|
|
const combined = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
|
const output = truncateHookOutput(combined);
|
|
log.info(`» stop hook failed with exit code ${result.exitCode}`);
|
|
return { exitCode: result.exitCode, output };
|
|
} catch (err) {
|
|
const isTimeout =
|
|
err instanceof SpawnTimeoutError &&
|
|
(err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
log.warning(
|
|
`stop hook ${isTimeout ? "timed out" : "failed to spawn"}: ${msg} — skipping retry`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function buildStopHookPrompt(failure: StopHookFailure): string {
|
|
return [
|
|
`STOP HOOK FAILED — the repo-configured stop hook exited with code ${failure.exitCode}. your work is not done until the hook exits cleanly. address the issue below and push any resulting changes to a pull request.`,
|
|
"",
|
|
"```",
|
|
failure.output || "(no output)",
|
|
"```",
|
|
].join("\n");
|
|
}
|
|
|
|
/** check whether the seeded summary file is byte-identical to its seed.
|
|
* a missing or unreadable file returns false (don't nudge — the agent
|
|
* may have legitimately deleted it, or the seed step failed; the read-
|
|
* back path in main.ts handles both cases by skipping persist). */
|
|
async function isSummaryUnchanged(filePath: string, seed: string): Promise<boolean> {
|
|
try {
|
|
const current = await readFile(filePath, "utf8");
|
|
return current === seed;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function buildSummaryStalePrompt(filePath: string): string {
|
|
return [
|
|
`PR SUMMARY UNTOUCHED — the rolling PR summary file at \`${filePath}\` is byte-identical to its seed; this run did not edit it.`,
|
|
"",
|
|
"review the diff and update the file in place to reflect what changed in the PR. update intent, key changes, and any risks worth flagging — keep the existing section headings stable so incremental runs produce clean diffs.",
|
|
"",
|
|
"if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is — but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context.",
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
export async function collectPostRunIssues(params: {
|
|
stopScript: string | null | undefined;
|
|
summaryFilePath?: string | undefined;
|
|
summarySeed?: string | undefined;
|
|
}): Promise<PostRunIssues> {
|
|
const issues: PostRunIssues = {};
|
|
if (params.stopScript) {
|
|
const failure = await executeStopHook(params.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 };
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
export function buildPostRunPrompt(issues: PostRunIssues): string {
|
|
const parts: string[] = [];
|
|
if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
|
|
if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
|
|
if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
|
|
return parts.join("\n\n---\n\n");
|
|
}
|
|
|
|
/**
|
|
* prompt for a dedicated post-run reflection turn nudging the agent to call
|
|
* `update_learnings` if it discovered anything worth persisting.
|
|
*
|
|
* this exists because the learnings step baked into mode checklists is
|
|
* frequently ignored — the agent stays focused on the task and the meta-ask
|
|
* falls through. delivering it as its own resume turn, with nothing competing
|
|
* for attention, raises the fire rate substantially.
|
|
*/
|
|
export function buildLearningsReflectionPrompt(agentId: AgentId): string {
|
|
const t = (name: string) => formatMcpToolRef(agentId, name);
|
|
return [
|
|
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs?`,
|
|
"",
|
|
`if so, call \`${t("update_learnings")}\` to persist it.`,
|
|
"",
|
|
`rules:`,
|
|
`- only call \`${t("update_learnings")}\` when the finding is high-confidence and broadly useful. skip if unsure, speculative, or one-off.`,
|
|
`- pass the FULL merged list: existing learnings from the original prompt + your new discoveries. one fact per bullet, lines starting with \`- \`.`,
|
|
`- deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`,
|
|
`- if you already called \`${t("update_learnings")}\` earlier in this run, or nothing new is worth capturing, just reply "done" and stop — do not edit the repo for this reflection.`,
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* shared post-run retry loop used by every agent harness.
|
|
*
|
|
* checks the post-run gates (stop hook + dirty tree), and if either is
|
|
* failing, invokes `resume` to let the agent fix and push in the same turn.
|
|
* bails at `MAX_POST_RUN_RETRIES` attempts. the `canResume` predicate is
|
|
* consulted before each retry — harnesses that can't re-enter the session
|
|
* (e.g. claude without a sessionId) return false here.
|
|
*
|
|
* an optional `reflectionPrompt` fires exactly once, after the gates first
|
|
* observe a clean state. it's a one-shot nudge (e.g. "update learnings if
|
|
* relevant"), not a gate, so it does not consume the gate-retry budget. if
|
|
* the reflection turn dirties the tree, the loop picks that up on the next
|
|
* iteration via the normal dirty-tree gate.
|
|
*
|
|
* stop hook must pass for the run to succeed; persistent hook failures are
|
|
* surfaced as `AgentResult.error`. dirty-tree-only failures preserve prior
|
|
* behavior: they're logged but don't fail the run.
|
|
*/
|
|
export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
|
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;
|
|
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
|
|
canResume?: ((result: R) => boolean) | undefined;
|
|
reflectionPrompt?: string | undefined;
|
|
}): Promise<AgentResult> {
|
|
let result = params.initialResult;
|
|
let aggregatedUsage = params.initialUsage;
|
|
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.
|
|
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,
|
|
});
|
|
if (issues.summaryStale) summaryStaleNudged = true;
|
|
finalIssues = issues;
|
|
|
|
if (!hasPostRunIssues(issues)) {
|
|
// gates are clean. if a reflection prompt is pending, deliver it once
|
|
// and loop back to re-check — the reflection may have touched the tree.
|
|
if (!pendingReflection) break;
|
|
if (params.canResume && !params.canResume(result)) break;
|
|
log.info("» post-run reflection: nudging agent to update learnings if relevant");
|
|
const preReflection = result;
|
|
const reflectionResult = await params.resume({
|
|
prompt: pendingReflection,
|
|
previousResult: result,
|
|
});
|
|
aggregatedUsage = mergeAgentUsage(aggregatedUsage, reflectionResult.usage);
|
|
pendingReflection = undefined;
|
|
if (!reflectionResult.success) {
|
|
// reflection is a best-effort nudge. its failure must not flip a
|
|
// successful run to failed — the gated work is already done. keep
|
|
// the pre-reflection result and exit without re-running the gates
|
|
// (which would risk a flaky false-positive hook failure right after
|
|
// it just passed).
|
|
log.warning(
|
|
`» reflection turn failed (${reflectionResult.error ?? "unknown error"}), preserving prior successful result`
|
|
);
|
|
result = preReflection;
|
|
break;
|
|
}
|
|
// reflection replies are meta-asks ("done", "updated learnings with N
|
|
// bullets") — not a task summary. keep the pre-reflection output so
|
|
// the returned AgentResult still reflects what the run accomplished,
|
|
// while inheriting reflection-specific fields the harness needs for
|
|
// any subsequent gate retry (e.g. the new sessionId claude emits per
|
|
// --resume invocation).
|
|
// use `||` (not `??`) so an empty pre-reflection output falls through
|
|
// to the reflection's reply. runs that only emit MCP tool calls and no
|
|
// plain text leave result.output = "" — keeping "" would starve the
|
|
// fallback path in handleAgentResult of anything to show.
|
|
result = {
|
|
...reflectionResult,
|
|
output: preReflection.output || reflectionResult.output,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
// checks still ran even if we can't resume, so the failure gate below
|
|
// can still catch a persistent stop-hook failure.
|
|
if (params.canResume && !params.canResume(result)) {
|
|
log.info("» post-run retry skipped: cannot resume agent session");
|
|
break;
|
|
}
|
|
|
|
log.info(`» post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`);
|
|
const prompt = buildPostRunPrompt(issues);
|
|
// summary-stale is a soft gate that must never flip a successful run to
|
|
// failed. when it's the only issue and the resume itself errors out,
|
|
// restore the pre-resume successful result and break — persistSummary
|
|
// detects the unchanged file via its seed comparison and skips the DB
|
|
// write on its own, so no further coordination is needed here.
|
|
const onlySummaryStale =
|
|
issues.summaryStale !== undefined &&
|
|
issues.stopHook === undefined &&
|
|
issues.dirtyTree === undefined;
|
|
const preResume = result;
|
|
result = await params.resume({ prompt, previousResult: result });
|
|
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
|
|
if (!result.success && onlySummaryStale) {
|
|
log.warning(
|
|
`» summary-stale resume turn failed (${result.error ?? "unknown error"}), preserving prior successful result`
|
|
);
|
|
result = preResume;
|
|
break;
|
|
}
|
|
gateResumeCount++;
|
|
}
|
|
|
|
// we exhausted retries without observing a clean state — finalIssues
|
|
// reflects pre-resume state, so re-check to see what the last resume
|
|
// actually did. when the subprocess failed we skip: its own error is more
|
|
// actionable than a stale "stop hook still failing" message. when the loop
|
|
// already observed a clean state we skip: re-running the hook risks flaky
|
|
// false-positive failures right after it just passed.
|
|
if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
|
|
// re-check the gates that can actually fail the run (stop hook /
|
|
// dirty tree). summary-stale is intentionally 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 });
|
|
}
|
|
|
|
if (result.success && finalIssues.stopHook) {
|
|
const retryNote =
|
|
gateResumeCount > 0
|
|
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
|
|
: "";
|
|
return {
|
|
...result,
|
|
success: false,
|
|
error: `stop hook failed${retryNote} (exit code ${finalIssues.stopHook.exitCode}): ${finalIssues.stopHook.output || "(no output)"}`,
|
|
usage: aggregatedUsage,
|
|
};
|
|
}
|
|
|
|
return { ...result, usage: aggregatedUsage };
|
|
}
|