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.
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import type { AgentId } from "../external.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
|
|
|
// maximum number of stderr lines to keep in the rolling buffer during agent execution
|
|
export const MAX_STDERR_LINES = 20;
|
|
|
|
// ── post-run retry loop ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* how many times the post-run loop may resume the agent to fix a dirty tree
|
|
* or a failing stop hook before giving up.
|
|
*/
|
|
export const MAX_POST_RUN_RETRIES = 3;
|
|
|
|
export function getGitStatus(): string {
|
|
try {
|
|
return execFileSync("git", ["status", "--porcelain"], {
|
|
encoding: "utf-8",
|
|
timeout: 10_000,
|
|
}).trim();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function buildCommitPrompt(status: string): string {
|
|
return [
|
|
`UNCOMMITTED CHANGES — the working tree is dirty. push all changes to a pull request (new or existing). \`git status\` must be clean before you finish.`,
|
|
"",
|
|
"```",
|
|
status,
|
|
"```",
|
|
].join("\n");
|
|
}
|
|
|
|
export interface StopHookFailure {
|
|
exitCode: number;
|
|
output: string;
|
|
}
|
|
|
|
export interface SummaryStale {
|
|
/** absolute path to the seeded snapshot file the agent was meant to edit. */
|
|
filePath: string;
|
|
}
|
|
|
|
export interface PostRunIssues {
|
|
stopHook?: StopHookFailure;
|
|
dirtyTree?: string;
|
|
/** populated when the rolling PR summary file is byte-identical to its
|
|
* seed, i.e. the agent never touched it. soft gate — nudges once via a
|
|
* resume turn but never fails the run, parallel to dirtyTree semantics. */
|
|
summaryStale?: SummaryStale;
|
|
}
|
|
|
|
export function hasPostRunIssues(issues: PostRunIssues): boolean {
|
|
return (
|
|
issues.stopHook !== undefined ||
|
|
issues.dirtyTree !== undefined ||
|
|
issues.summaryStale !== undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* token/cost usage data from a single agent run.
|
|
*
|
|
* NOTE on semantics: `inputTokens` here is the *total* billable input for the
|
|
* run — non-cached input + cache read + cache write — matching the per-agent
|
|
* SDK conventions. This is what gets persisted to `WorkflowRun.inputTokens`.
|
|
*
|
|
* The stdout token table and markdown step summary display a different "Input"
|
|
* column that shows only the non-cached portion (derivable as
|
|
* `inputTokens - cacheReadTokens - cacheWriteTokens`) so humans can see the
|
|
* cache hit ratio at a glance. Dashboards that query `WorkflowRun.inputTokens`
|
|
* directly are seeing the full total, not the log column.
|
|
*/
|
|
export interface AgentUsage {
|
|
agent: string;
|
|
/** full billable input: non-cached + cache read + cache write */
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cacheReadTokens?: number | undefined;
|
|
cacheWriteTokens?: number | undefined;
|
|
costUsd?: number | undefined;
|
|
}
|
|
|
|
export interface AgentToolUseEvent {
|
|
toolName: string;
|
|
input: unknown;
|
|
}
|
|
|
|
/**
|
|
* Result returned by agent execution
|
|
*/
|
|
export interface AgentResult {
|
|
success: boolean;
|
|
output?: string | undefined;
|
|
error?: string | undefined;
|
|
metadata?: Record<string, unknown>;
|
|
usage?: AgentUsage | undefined;
|
|
}
|
|
|
|
/**
|
|
* Minimal context passed to agent.run()
|
|
*/
|
|
export interface AgentRunContext {
|
|
payload: ResolvedPayload;
|
|
resolvedModel?: string | undefined;
|
|
mcpServerUrl: string;
|
|
tmpdir: string;
|
|
instructions: ResolvedInstructions;
|
|
todoTracker?: TodoTracker | undefined;
|
|
/**
|
|
* user-configured stop hook script. runs after the agent finishes each
|
|
* attempt; non-zero exit resumes the agent with the hook output as
|
|
* guidance. null when the repo has no stop hook configured.
|
|
*/
|
|
stopScript?: string | null | undefined;
|
|
/**
|
|
* absolute path to the rolling PR summary tmpfile, when one was seeded
|
|
* for this run (Review / IncrementalReview / pr-summary Task). enables
|
|
* a post-run sanity nudge that prompts the agent if the file is still
|
|
* byte-identical to its seed.
|
|
*/
|
|
summaryFilePath?: string | undefined;
|
|
/**
|
|
* exact bytes of the seeded summary file. compared against the current
|
|
* file content after each agent attempt to detect "agent forgot to edit
|
|
* the summary" — particularly common with smaller models that lose
|
|
* track of multi-step instructions.
|
|
*/
|
|
summarySeed?: string | undefined;
|
|
/**
|
|
* called synchronously when the agent subprocess is killed for inner
|
|
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
|
|
* server) so lingering SSE reconnects don't keep the outer timer alive.
|
|
*/
|
|
onActivityTimeout?: (() => void) | undefined;
|
|
onToolUse?: ((event: AgentToolUseEvent) => void) | undefined;
|
|
}
|
|
|
|
export interface Agent {
|
|
name: AgentId;
|
|
install: (token?: string) => Promise<string>;
|
|
run: (ctx: AgentRunContext) => Promise<AgentResult>;
|
|
}
|
|
|
|
export const agent = (input: Agent): Agent => {
|
|
return {
|
|
...input,
|
|
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
|
log.debug(`» payload: ${JSON.stringify(ctx.payload, null, 2)}`);
|
|
return input.run(ctx);
|
|
},
|
|
};
|
|
};
|
|
|
|
/** format a USD cost to 4 decimal places, always showing the leading zero */
|
|
export function formatCostUsd(costUsd: number): string {
|
|
return costUsd.toFixed(4);
|
|
}
|
|
|
|
/**
|
|
* merge two AgentUsage snapshots into one running total.
|
|
*
|
|
* both agent harnesses invoke their runner multiple times per `run()` when the
|
|
* post-run retry loop kicks in (MAX_POST_RUN_RETRIES). each invocation
|
|
* produces its own AgentUsage; we sum them so downstream callers (usage
|
|
* summary, WorkflowRun persistence) see the whole session — not just the
|
|
* final retry's slice.
|
|
*
|
|
* returns `undefined` when both sides are empty so callers can short-circuit
|
|
* without a special case. zero-valued cache / cost fields are dropped to
|
|
* `undefined` for symmetry with each harness's `buildUsage`.
|
|
*/
|
|
export function mergeAgentUsage(
|
|
a: AgentUsage | undefined,
|
|
b: AgentUsage | undefined
|
|
): AgentUsage | undefined {
|
|
// always return a fresh object — callers treat AgentUsage as immutable, and
|
|
// returning `a` / `b` directly would leak that invariant to future callers
|
|
if (!a && !b) return undefined;
|
|
if (!a) return { ...(b as AgentUsage) };
|
|
if (!b) return { ...a };
|
|
const cacheRead = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
|
|
const cacheWrite = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
|
|
const cost = (a.costUsd ?? 0) + (b.costUsd ?? 0);
|
|
return {
|
|
agent: a.agent,
|
|
inputTokens: a.inputTokens + b.inputTokens,
|
|
outputTokens: a.outputTokens + b.outputTokens,
|
|
cacheReadTokens: cacheRead > 0 ? cacheRead : undefined,
|
|
cacheWriteTokens: cacheWrite > 0 ? cacheWrite : undefined,
|
|
costUsd: cost > 0 ? cost : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* unified per-run token table used by every agent harness.
|
|
*
|
|
* columns are kept stable across agents and models so downstream log parsers
|
|
* (scripts/token-usage.ts, cost dashboards) only have to understand one format:
|
|
*
|
|
* Input non-cached input tokens sent this run
|
|
* Cache Read input tokens served from prompt cache (Anthropic, etc.)
|
|
* Cache Write input tokens written to prompt cache this run
|
|
* Output assistant output tokens
|
|
* Total sum of the four columns — the real billable quantity
|
|
* Cost ($) USD cost reported by the provider (only rendered when known)
|
|
*
|
|
* models that don't report prompt caching leave Cache Read / Write at 0.
|
|
* OpenCode emits per-step `part.cost` sourced from models.dev (works across
|
|
* Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, OpenRouter, etc.);
|
|
* Claude CLI emits `total_cost_usd` on its final `result` event. pass the
|
|
* accumulated value via `costUsd` to render the Cost column.
|
|
*/
|
|
export function logTokenTable(t: {
|
|
input: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
output: number;
|
|
costUsd?: number | undefined;
|
|
}): void {
|
|
const total = t.input + t.cacheRead + t.cacheWrite + t.output;
|
|
// narrow costUsd to a concrete number so the render path doesn't need a cast
|
|
const costUsd = typeof t.costUsd === "number" && t.costUsd > 0 ? t.costUsd : undefined;
|
|
|
|
const headerRow: Array<{ data: string; header: true }> = [
|
|
{ data: "Input", header: true },
|
|
{ data: "Cache Read", header: true },
|
|
{ data: "Cache Write", header: true },
|
|
{ data: "Output", header: true },
|
|
{ data: "Total", header: true },
|
|
];
|
|
const dataRow: string[] = [
|
|
String(t.input),
|
|
String(t.cacheRead),
|
|
String(t.cacheWrite),
|
|
String(t.output),
|
|
String(total),
|
|
];
|
|
|
|
if (costUsd !== undefined) {
|
|
headerRow.push({ data: "Cost ($)", header: true });
|
|
dataRow.push(formatCostUsd(costUsd));
|
|
}
|
|
|
|
log.table([headerRow, dataRow]);
|
|
}
|