Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4d0fc7e3d | |||
| a4a5010441 | |||
| cf94773bf0 | |||
| 8e36f76cfa | |||
| dee13b160f | |||
| ef394277c1 | |||
| ee479474ce | |||
| 96910f0f50 | |||
| 4cc6d95a91 | |||
| 10590993f4 | |||
| 10aeaf8c11 | |||
| 85d25a6fe6 | |||
| 653fae47a5 | |||
| 363e4cbed8 |
+89
-8
@@ -146,6 +146,15 @@ interface ClaudeUserEvent {
|
||||
interface ClaudeResultEvent {
|
||||
type: "result";
|
||||
subtype?: string;
|
||||
// claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
|
||||
// an upstream provider fails mid-stream. `api_error_status` carries the
|
||||
// provider HTTP status (e.g. 401 for invalid API key). per the official
|
||||
// SDK types, `api_error_status` is `number | null`, and the `error_*`
|
||||
// subtypes carry their actionable payload in `errors: string[]` instead
|
||||
// of `result`.
|
||||
is_error?: boolean;
|
||||
api_error_status?: number | null;
|
||||
errors?: string[];
|
||||
result?: string;
|
||||
session_id?: string;
|
||||
num_turns?: number;
|
||||
@@ -203,7 +212,25 @@ type RunParams = {
|
||||
|
||||
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
|
||||
|
||||
async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
/**
|
||||
* Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
|
||||
* dropping any partial first line. used in the exit-non-zero stdout fallback
|
||||
* so we never surface a truncated NDJSON event to operators —
|
||||
* `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
|
||||
* syntactically broken JSON fragment. code units rather than bytes because
|
||||
* `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
|
||||
* content the effective byte budget can be up to 4× the nominal limit.
|
||||
*/
|
||||
function tailLines(text: string, maxCodeUnits: number): string {
|
||||
if (text.length <= maxCodeUnits) return text;
|
||||
const tail = text.slice(-maxCodeUnits);
|
||||
const firstNewline = tail.indexOf("\n");
|
||||
// if no newline in window or it's at the very start, return as-is;
|
||||
// otherwise drop the partial first line.
|
||||
return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
|
||||
}
|
||||
|
||||
export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
const startTime = performance.now();
|
||||
let eventCount = 0;
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
@@ -211,6 +238,22 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
let finalOutput = "";
|
||||
let sessionId: string | undefined;
|
||||
let resultErrorSubtype: string | null = null;
|
||||
// captures the structured error string from a result event with
|
||||
// `is_error: true` (e.g. mid-stream provider auth failures the CLI
|
||||
// surfaces as `subtype: "success"` synthetic-stop events, or the
|
||||
// `errors[]` array from `error_*` subtypes). preferred over raw
|
||||
// stdout/stderr in the exit-non-zero path so the GitHub Actions
|
||||
// `##[error]` line shows the actionable message instead of an 8KB+
|
||||
// NDJSON dump.
|
||||
let lastResultError: string | null = null;
|
||||
// set only for synthetic-stop `subtype: "success"` + `is_error: true`
|
||||
// events, where `accumulatedTokens` from prior `assistant` events is
|
||||
// stale and logging it would mislead operators into thinking billable
|
||||
// tokens were spent on a successful turn. deliberately NOT set for
|
||||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||||
// because those runs genuinely consumed tokens and operators need
|
||||
// billing visibility for them.
|
||||
let syntheticStopFailure = false;
|
||||
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
// Claude CLI reports a single end-of-run `total_cost_usd` on the result
|
||||
// event. per-message events don't carry cost, so there's nothing to sum —
|
||||
@@ -335,6 +378,27 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
const subtype = event.subtype || "unknown";
|
||||
const numTurns = event.num_turns || 0;
|
||||
|
||||
// claude CLI emits synthetic-stop result events with `subtype: "success"`
|
||||
// but `is_error: true` when an upstream provider fails mid-stream (e.g.
|
||||
// 401 from anthropic). short-circuit before the usage/token-table path
|
||||
// so we don't log a usage table for a failed attempt and so downstream
|
||||
// (`resultErrorSubtype` branch) surfaces the structured error. gated on
|
||||
// `subtype === "success"` because the `error_*` subtypes also set
|
||||
// `is_error: true` but carry their payload in `errors: string[]` and
|
||||
// are handled by the dedicated branches below.
|
||||
if (event.is_error === true && subtype === "success") {
|
||||
const apiStatus = event.api_error_status;
|
||||
lastResultError =
|
||||
event.result?.trim() ||
|
||||
`claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
|
||||
resultErrorSubtype = subtype;
|
||||
syntheticStopFailure = true;
|
||||
log.info(
|
||||
`» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtype === "success") {
|
||||
// extract detailed usage from result event (most accurate source).
|
||||
// note: `input` here is non-cached input tokens only, matching the
|
||||
@@ -369,12 +433,15 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
}
|
||||
} else if (subtype === "error_max_turns") {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
|
||||
} else if (subtype === "error_during_execution") {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
|
||||
} else if (subtype.startsWith("error")) {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||||
} else {
|
||||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||||
@@ -496,8 +563,16 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
||||
}
|
||||
|
||||
// skip the fallback token table only for the synthetic-stop
|
||||
// `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
|
||||
// prior `assistant` events is stale there and logging it would mislead
|
||||
// operators into thinking billable tokens were spent on a successful turn.
|
||||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||||
// represent runs that genuinely consumed tokens, so they still get the
|
||||
// table for billing visibility.
|
||||
if (
|
||||
!tokensLogged &&
|
||||
!syntheticStopFailure &&
|
||||
(accumulatedTokens.input > 0 ||
|
||||
accumulatedTokens.output > 0 ||
|
||||
accumulatedTokens.cacheRead > 0 ||
|
||||
@@ -511,9 +586,17 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
// prefer the structured `lastResultError` (parsed from a result event
|
||||
// with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
|
||||
// event stream — dumping it into a GitHub Actions `##[error]` line both
|
||||
// hides the actionable provider message and pollutes the run log. cap
|
||||
// the stdout fallback to the last 2KB so it stays readable when neither
|
||||
// a structured error nor stderr is available.
|
||||
const truncatedStdout = result.stdout ? tailLines(result.stdout, 2048) : "";
|
||||
const errorMessage =
|
||||
lastResultError ||
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
truncatedStdout ||
|
||||
`unknown error - no output from Claude CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
@@ -543,7 +626,7 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `result subtype: ${resultErrorSubtype}`,
|
||||
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
||||
usage,
|
||||
sessionId,
|
||||
};
|
||||
@@ -722,13 +805,11 @@ export const claude = agent({
|
||||
// the run). the reflection prompt fires once after gates go clean, as a
|
||||
// dedicated turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
ctx,
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
summaryFilePath: ctx.summaryFilePath,
|
||||
summarySeed: ctx.summarySeed,
|
||||
reflectionPrompt: ctx.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.learningsFilePath)
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
canResume: (r) => Boolean(r.sessionId),
|
||||
resume: async (c) => {
|
||||
|
||||
+37
-5
@@ -80,6 +80,26 @@ type OpenCodeConfig = {
|
||||
*/
|
||||
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
||||
|
||||
/**
|
||||
* upstream opencode hardcodes `thinkingLevel: "high"` as the default for every
|
||||
* gemini-3 model on the direct google SDK (`provider/transform.ts` `options()`).
|
||||
* that adds 30-60s of pre-tool-call TTFT and 5-46s of post-tool jabber per turn,
|
||||
* which is overkill for agentic loops where most steps are tool-routing
|
||||
* decisions. we override to "medium" for the curated slugs we ship in
|
||||
* `action/models.ts`; users who want max quality can still pick the `-high`
|
||||
* variant explicitly. flash stays at "medium" too — low-effort flash is
|
||||
* visibly worse on harder tasks and the latency savings aren't meaningful
|
||||
* (flash is already fast). other gemini-3 ids that exist in models.dev but
|
||||
* aren't in our curated alias map keep the upstream `"high"` default.
|
||||
*
|
||||
* keyed by upstream api id (matches the slugs in `action/models.ts`). the
|
||||
* merge order in opencode `session/llm.ts` is `base ← model.options ← agent.options ← variant`,
|
||||
* deep-merged — so an explicit `--variant high` still wins, and explicit
|
||||
* model.options in a user-provided opencode config would also win.
|
||||
*/
|
||||
const GEMINI_3_DIRECT_THINKING_LEVEL = "medium";
|
||||
const GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"];
|
||||
|
||||
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
||||
const config: OpenCodeConfig = {
|
||||
permission: {
|
||||
@@ -94,6 +114,20 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||
},
|
||||
agent: buildReviewerAgentConfig(),
|
||||
provider: {
|
||||
google: {
|
||||
models: Object.fromEntries(
|
||||
GEMINI_3_DIRECT_API_IDS.map((id) => [
|
||||
id,
|
||||
{
|
||||
options: {
|
||||
thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL },
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (model) {
|
||||
@@ -1145,13 +1179,11 @@ export const opencode = agent({
|
||||
// the reflection prompt fires once after gates go clean, as a dedicated
|
||||
// turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
ctx,
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
summaryFilePath: ctx.summaryFilePath,
|
||||
summarySeed: ctx.summarySeed,
|
||||
reflectionPrompt: ctx.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.learningsFilePath)
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
resume: async (c) =>
|
||||
runOpenCode({
|
||||
|
||||
+35
-414
@@ -1,429 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SPAWN_TIMEOUT_CODE, SpawnTimeoutError } from "../utils/subprocess.ts";
|
||||
import type { AgentResult } from "./shared.ts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { getUnsubmittedReview } from "./postRun.ts";
|
||||
|
||||
vi.mock("./shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./shared.ts")>();
|
||||
function makeToolState(overrides: Partial<ToolState> = {}): ToolState {
|
||||
return {
|
||||
...actual,
|
||||
getGitStatus: vi.fn(() => ""),
|
||||
progressComment: undefined,
|
||||
hadProgressComment: true,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../utils/subprocess.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/subprocess.ts")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { runPostRunRetryLoop, executeStopHook } = await import("./postRun.ts");
|
||||
const { getGitStatus } = await import("./shared.ts");
|
||||
const { spawn } = await import("../utils/subprocess.ts");
|
||||
const mockedGetGitStatus = vi.mocked(getGitStatus);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
|
||||
const successResult = (overrides: Partial<AgentResult> = {}): AgentResult => ({
|
||||
success: true,
|
||||
output: "ok",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("runPostRunRetryLoop — reflection turn", () => {
|
||||
beforeEach(() => {
|
||||
mockedGetGitStatus.mockReset();
|
||||
mockedGetGitStatus.mockReturnValue("");
|
||||
mockedSpawn.mockReset();
|
||||
describe("getUnsubmittedReview", () => {
|
||||
it("returns null when mode is not a review mode", () => {
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Build" }))).toBeNull();
|
||||
expect(getUnsubmittedReview(makeToolState())).toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
it("returns null when a review was already submitted", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(
|
||||
makeToolState({
|
||||
selectedMode: "Review",
|
||||
review: { id: 1, nodeId: "n", reviewedSha: undefined },
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flip a successful run to failed when reflection returns success:false", async () => {
|
||||
// the reflection turn is a best-effort nudge (edit the learnings
|
||||
// tmpfile). if it fails — e.g. the model API errors mid-turn — the
|
||||
// underlying task has already completed and been gated cleanly, so the
|
||||
// run as a whole must still be reported as successful.
|
||||
const initial = successResult({ output: "task done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({ success: false, error: "model API transient failure" });
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: edit learnings file if anything is worth saving",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("task done");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(resume).toHaveBeenCalledTimes(1);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toMatch(/REFLECTION/);
|
||||
it("returns null when report_progress wrote a final summary", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(makeToolState({ selectedMode: "Review", finalSummaryWritten: true }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still aggregates usage from a failed reflection turn", async () => {
|
||||
// the reflection consumed tokens even if it didn't produce useful output;
|
||||
// the run total must reflect that so billing/reporting stays accurate.
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: false,
|
||||
error: "model API transient failure",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.usage?.inputTokens).toBe(110);
|
||||
expect(result.usage?.outputTokens).toBe(55);
|
||||
it("returns null when there is no progress comment to anchor the failure to", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(makeToolState({ selectedMode: "Review", hadProgressComment: false }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the reflection's output when the pre-reflection output is empty", async () => {
|
||||
// the preservation fix must only kick in when the task actually produced
|
||||
// meaningful output. runs that communicate exclusively through MCP tools
|
||||
// (e.g. report_progress) leave result.output = "" — using `??` here kept
|
||||
// the empty string and dropped the reflection's reply, leaving the
|
||||
// fallback `handleAgentResult` path with nothing to show. prefer the
|
||||
// reflection's output (even a trivial "done") over no output at all.
|
||||
const initial = successResult({ output: "" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider editing learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("done");
|
||||
});
|
||||
|
||||
it("preserves the pre-reflection task output when a trivial reflection ('done') succeeds", async () => {
|
||||
// the reflection turn is a meta-ask — its literal reply ("done" or a
|
||||
// short "updated learnings with N bullets") is not the task summary the
|
||||
// caller wants to see. before this fix, `result = reflectionResult`
|
||||
// clobbered the task's output on the returned AgentResult, so downstream
|
||||
// consumers (handleAgentResult's fallback path when toolState is empty,
|
||||
// programmatic callers of main()) saw "done" instead of the real
|
||||
// summary. assert the task's output survives a successful reflection.
|
||||
const initial = successResult({ output: "Implemented feature X; tests pass; pushed PR #42" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider editing learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("Implemented feature X; tests pass; pushed PR #42");
|
||||
});
|
||||
|
||||
it("skips reflection entirely when canResume returns false", async () => {
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
canResume: () => false,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches a reflection turn that dirties the tree via the dirty-tree gate on the next iteration", async () => {
|
||||
// PR claims: "if the reflection turn dirties the tree, the loop picks
|
||||
// that up on the next iteration via the normal dirty-tree gate." lock
|
||||
// it in — without this invariant the reflection prompt could bypass the
|
||||
// commit-before-you-finish contract whenever the agent misbehaves.
|
||||
//
|
||||
// three getGitStatus calls in sequence:
|
||||
// 1. clean (triggers reflection)
|
||||
// 2. reflection left the tree dirty
|
||||
// 3. retry committed the changes — now clean, loop exits
|
||||
mockedGetGitStatus
|
||||
.mockReturnValueOnce("")
|
||||
.mockReturnValueOnce(" M scratch/notes.md")
|
||||
.mockReturnValueOnce("");
|
||||
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "resumed" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider editing learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// call 0: reflection; call 1: dirty-tree retry
|
||||
expect(resume).toHaveBeenCalledTimes(2);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toContain("REFLECTION");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("scratch/notes.md");
|
||||
});
|
||||
|
||||
it("surfaces a persistent stop hook failure as AgentResult.error after MAX_POST_RUN_RETRIES", async () => {
|
||||
// PR test plan item #1: "confirm the agent is resumed with the hook
|
||||
// output and the run fails after 3 attempts if never resolved."
|
||||
//
|
||||
// stop the hook from passing on every invocation, have `resume` always
|
||||
// return success (the agent tried but couldn't fix the issue), and
|
||||
// verify: (a) the loop exhausts all retries, (b) the final result is
|
||||
// success=false, (c) the error mentions the retry count and the hook
|
||||
// output verbatim so the GitHub comment surfaces what actually failed.
|
||||
const hookFailure = {
|
||||
stdout: "lint: 3 issues in src/foo.ts",
|
||||
stderr: "",
|
||||
exitCode: 7,
|
||||
durationMs: 5,
|
||||
};
|
||||
mockedSpawn.mockResolvedValue(hookFailure);
|
||||
|
||||
const initial = successResult({ output: "agent done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "retry done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: undefined,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("exit code 7");
|
||||
expect(result.error).toContain("3 retry attempts");
|
||||
expect(result.error).toContain("lint: 3 issues in src/foo.ts");
|
||||
// each retry feeds the hook output back into the agent as the resume prompt
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("STOP HOOK FAILED");
|
||||
expect(call[0].prompt).toContain("lint: 3 issues in src/foo.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a persistently dirty tree (no stop hook failure) as a soft-fail", async () => {
|
||||
// the PR documents: "dirty-tree-only failures preserve prior behavior:
|
||||
// they're logged but don't fail the run." a regression that started
|
||||
// surfacing dirty-tree as AgentResult.error would make every run that
|
||||
// leaves untracked test fixtures around fail spuriously. guard it.
|
||||
mockedGetGitStatus.mockReturnValue(" M src/foo.ts");
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "tried but tree still dirty" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
// retries were attempted (the loop fed the dirty-tree prompt back to the agent)
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a stop hook failure even when canResume is false (no retry budget, still fails the run)", async () => {
|
||||
// the retry loop is best-effort. when canResume says no (e.g. claude
|
||||
// without a sessionId), we still need the failure gate to fire so the
|
||||
// user sees WHY the run failed instead of an opaque success. covers the
|
||||
// "checks still ran even if we can't resume" comment in postRun.ts.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "typecheck: 2 errors",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm typecheck",
|
||||
resume,
|
||||
canResume: () => false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("typecheck: 2 errors");
|
||||
// no retries were attempted because canResume said no — error lists no
|
||||
// retry count (that would be a lie).
|
||||
expect(result.error).not.toContain("retry attempt");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits the loop when the initial result is already failed", async () => {
|
||||
// if the agent already failed (timeout, model error) there's no point
|
||||
// running gates or a reflection — the run is toast. preserve the original
|
||||
// error verbatim so triage is straightforward.
|
||||
const initial: AgentResult = {
|
||||
success: false,
|
||||
error: "agent died mid-turn",
|
||||
output: "partial",
|
||||
};
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("agent died mid-turn");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedGetGitStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aggregates usage across every gate retry", async () => {
|
||||
// billing/reporting rely on the usage summary reflecting the full run,
|
||||
// not just the final retry's slice. regression gate.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "fail",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
output: "retry",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: "flaky",
|
||||
resume,
|
||||
});
|
||||
|
||||
// 100 initial + 10 * 3 retries = 130
|
||||
expect(result.usage?.inputTokens).toBe(130);
|
||||
expect(result.usage?.outputTokens).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeStopHook — output capture", () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("includes both stdout and stderr in the failure output when both are populated", async () => {
|
||||
// hooks that wrap other tools commonly emit a benign warning to stderr
|
||||
// (e.g. "config file not found, using defaults") and the actionable error
|
||||
// to stdout. a `(stderr || stdout)` heuristic drops stdout entirely
|
||||
// whenever stderr is non-empty, starving the agent of the information it
|
||||
// needs to fix the issue.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "ERROR: lint check failed at path/to/file.ts:42",
|
||||
stderr: "Warning: config file not found, using defaults",
|
||||
exitCode: 1,
|
||||
durationMs: 5,
|
||||
});
|
||||
const failure = await executeStopHook("run-lint");
|
||||
expect(failure).not.toBeNull();
|
||||
expect(failure?.output).toContain("ERROR: lint check failed at path/to/file.ts:42");
|
||||
expect(failure?.output).toContain("Warning: config file not found, using defaults");
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) when spawn throws a timeout", async () => {
|
||||
// infra-level failures can't be fixed by the agent. surfacing them as a
|
||||
// hook failure would put the loop into a retry cycle that never
|
||||
// terminates. soft-fail and let the run succeed.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
new SpawnTimeoutError("hook exceeded 10 minutes", SPAWN_TIMEOUT_CODE)
|
||||
it("returns the selected mode when the gate should fire", () => {
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Review" }))).toBe("Review");
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "IncrementalReview" }))).toBe(
|
||||
"IncrementalReview"
|
||||
);
|
||||
const failure = await executeStopHook("slow-hook");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) on spawn ENOENT (command not found)", async () => {
|
||||
// if the user misconfigures the hook (wrong binary, typo), the spawn
|
||||
// itself throws. same rationale as timeouts: soft-fail, don't retry.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error("spawn nosuchbin ENOENT"), { code: "ENOENT" })
|
||||
);
|
||||
const failure = await executeStopHook("nosuchbin");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates oversize output, keeping the tail", async () => {
|
||||
// the error is embedded in AgentResult.error and flows into GitHub
|
||||
// comments (65535-char cap). the 4096-char truncation is our guardrail;
|
||||
// lock it in so a well-meaning refactor can't blow the comment budget.
|
||||
const longTail = "LAST_LINE_IS_ACTIONABLE";
|
||||
const longOutput = "x".repeat(10_000) + longTail;
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: longOutput,
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const failure = await executeStopHook("noisy");
|
||||
expect(failure?.output).toContain(longTail);
|
||||
expect(failure?.output).toContain("truncated");
|
||||
expect(failure?.output.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
|
||||
+120
-41
@@ -1,5 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
|
||||
import { NON_COMMITTING_MODES } from "../modes.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import {
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
} from "../utils/subprocess.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentRunContext,
|
||||
type AgentUsage,
|
||||
buildCommitPrompt,
|
||||
getGitStatus,
|
||||
@@ -19,6 +22,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
|
||||
@@ -115,41 +135,90 @@ export function buildSummaryStalePrompt(filePath: string): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildUnsubmittedReviewPrompt(mode: "Review" | "IncrementalReview"): string {
|
||||
// mode-aware: Review mode's contract is "always submit one review" — its
|
||||
// mode prompt forbids `report_progress`, so the nudge here must not offer
|
||||
// it as an exit. IncrementalReview legitimately allows a report_progress
|
||||
// exit when there are no new issues since the last review (mode prompt
|
||||
// step 8), so the nudge mirrors that contract.
|
||||
if (mode === "Review") {
|
||||
return [
|
||||
`MISSING REVIEW OUTPUT — you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
|
||||
"",
|
||||
"call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt — Review mode has no no-submit exit, so even informational `> [!NOTE]` reviews and `No new issues found.` reviews must be submitted (both use `approved: true`). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
|
||||
"",
|
||||
"do NOT stop again until `create_pull_request_review` has been called successfully.",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`MISSING REVIEW OUTPUT — you selected IncrementalReview mode but stopped without calling \`create_pull_request_review\` or \`report_progress\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
|
||||
"",
|
||||
"do exactly one of:",
|
||||
"- if you have findings: call `create_pull_request_review` now with your aggregated review (body + inline comments). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
|
||||
"- if there are genuinely no actionable findings since the last review (e.g. only formatting / comment / lockfile changes): call `report_progress` with a 1-2 sentence summary explaining that no review was warranted.",
|
||||
"",
|
||||
"do NOT stop again until one of those tools has been called successfully.",
|
||||
].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.
|
||||
* 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;
|
||||
}): 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;
|
||||
}
|
||||
// dirty-tree gate fires only in modes that legitimately commit. Review /
|
||||
// IncrementalReview / Plan complete via review submission or a Plan
|
||||
// comment, not by touching files — any tree dirt is incidental (e.g. a
|
||||
// tool-installed `node_modules/`) and the worktree is ephemeral, so
|
||||
// nudging the agent to commit it would produce a spurious PR. see
|
||||
// `NON_COMMITTING_MODES` in `action/modes.ts`.
|
||||
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 };
|
||||
const mode = ctx.toolState.selectedMode;
|
||||
if (status) {
|
||||
if (mode && NON_COMMITTING_MODES.has(mode)) {
|
||||
log.info(`» dirty-tree gate suppressed: mode \`${mode}\` does not commit`);
|
||||
} else {
|
||||
issues.dirtyTree = status;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export function buildPostRunPrompt(issues: PostRunIssues): string {
|
||||
// order matches the terminal hard-fail order in `runPostRunRetryLoop` so
|
||||
// the prompt's emphasis (which gate the agent should fix first) lines up
|
||||
// with the user-visible failure message reported when retries exhaust.
|
||||
// both hard-fail gates first (`stopHook` → `unsubmittedReview`), then the
|
||||
// soft gates (`dirtyTree` → `summaryStale`).
|
||||
const parts: string[] = [];
|
||||
if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
|
||||
if (issues.unsubmittedReview) {
|
||||
parts.push(buildUnsubmittedReviewPrompt(issues.unsubmittedReview));
|
||||
}
|
||||
if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
|
||||
if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
|
||||
return parts.join("\n\n---\n\n");
|
||||
@@ -203,17 +272,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;
|
||||
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
|
||||
canResume?: ((result: R) => boolean) | undefined;
|
||||
reflectionPrompt?: string | undefined;
|
||||
@@ -223,19 +284,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,
|
||||
const issues = await collectPostRunIssues(params.ctx, {
|
||||
skipSummaryStale: summaryStaleNudged,
|
||||
});
|
||||
if (issues.summaryStale) summaryStaleNudged = true;
|
||||
finalIssues = issues;
|
||||
@@ -321,10 +379,11 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
// 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 });
|
||||
// dirty tree / unsubmitted review). 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(params.ctx, { skipSummaryStale: true });
|
||||
}
|
||||
|
||||
if (result.success && finalIssues.stopHook) {
|
||||
@@ -340,5 +399,25 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (result.success && finalIssues.unsubmittedReview) {
|
||||
const retryNote =
|
||||
gateResumeCount > 0
|
||||
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
|
||||
: "";
|
||||
// mode-aware: Review's contract requires a review submission; only
|
||||
// IncrementalReview accepts `report_progress` as an exit. mirroring
|
||||
// the nudge prompt avoids contradicting the agent-facing copy.
|
||||
const expected =
|
||||
finalIssues.unsubmittedReview === "Review"
|
||||
? "create_pull_request_review"
|
||||
: "create_pull_request_review or report_progress";
|
||||
return {
|
||||
...result,
|
||||
success: false,
|
||||
error: `${finalIssues.unsubmittedReview} mode finished without calling ${expected}${retryNote}`,
|
||||
usage: aggregatedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...result, usage: aggregatedUsage };
|
||||
}
|
||||
|
||||
+28
-21
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { AgentId } from "../external.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
@@ -54,13 +55,25 @@ export interface PostRunIssues {
|
||||
* 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;
|
||||
/**
|
||||
* populated when the agent selected a review mode but the post-run check
|
||||
* over toolState shows neither a `create_pull_request_review` submission
|
||||
* nor a final `report_progress` write happened. derived inline from
|
||||
* `toolState.selectedMode` + `toolState.review` + `toolState.finalSummaryWritten`
|
||||
* via {@link getUnsubmittedReview} — no parallel toolState flag is stored.
|
||||
* carries the mode name so the resume prompt can reference it. handled like
|
||||
* `stopHook`: nudge via resume, hard-fail if still unsatisfied after
|
||||
* `MAX_POST_RUN_RETRIES`.
|
||||
*/
|
||||
unsubmittedReview?: "Review" | "IncrementalReview";
|
||||
}
|
||||
|
||||
export function hasPostRunIssues(issues: PostRunIssues): boolean {
|
||||
return (
|
||||
issues.stopHook !== undefined ||
|
||||
issues.dirtyTree !== undefined ||
|
||||
issues.summaryStale !== undefined
|
||||
issues.summaryStale !== undefined ||
|
||||
issues.unsubmittedReview !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +117,14 @@ export interface AgentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal context passed to agent.run()
|
||||
* Context passed to agent.run() and threaded through the post-run loop.
|
||||
*
|
||||
* design rule: this is the single object that flows through the harness and
|
||||
* downstream utilities by reference. derived predicates (e.g.
|
||||
* `getUnsubmittedReview`), tmpfile paths, and seed bytes live on
|
||||
* `toolState` — read them at the call site, do not duplicate them onto this
|
||||
* interface. utilities that need run state should accept `ctx` whole, not
|
||||
* destructure a narrow subset.
|
||||
*/
|
||||
export interface AgentRunContext {
|
||||
payload: ResolvedPayload;
|
||||
@@ -120,26 +140,13 @@ export interface AgentRunContext {
|
||||
*/
|
||||
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.
|
||||
* mutable per-run state shared with the MCP server (by reference). post-run
|
||||
* gates read fresh values from it after each agent attempt — `summaryFilePath`,
|
||||
* `summarySeed`, `selectedMode`, `review`, `finalSummaryWritten`,
|
||||
* `hadProgressComment` are all consulted by `collectPostRunIssues`. see
|
||||
* `action/toolState.ts` for the literal-state design rule.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* absolute path to the rolling repo-level learnings tmpfile. seeded for
|
||||
* every run from `Repo.learnings`. used by the post-run reflection turn
|
||||
* so the prompt can point the agent at a concrete path to edit; the
|
||||
* file's content is read back and persisted by main.ts after the run.
|
||||
*/
|
||||
learningsFilePath?: string | undefined;
|
||||
toolState: ToolState;
|
||||
/**
|
||||
* called synchronously when the agent subprocess is killed for inner
|
||||
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
|
||||
|
||||
@@ -6,13 +6,9 @@ import { join } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
|
||||
import { startInstallation } from "./mcp/dependencies.ts";
|
||||
import {
|
||||
initToolState,
|
||||
startMcpHttpServer,
|
||||
type ToolContext,
|
||||
type ToolState,
|
||||
} from "./mcp/server.ts";
|
||||
import { startMcpHttpServer, type ToolContext } from "./mcp/server.ts";
|
||||
import { computeModes } from "./modes.ts";
|
||||
import { initToolState, type ToolState } from "./toolState.ts";
|
||||
import {
|
||||
type ActivityTimeout,
|
||||
createProcessOutputActivityTimeout,
|
||||
@@ -514,9 +510,14 @@ async function persistSummary(ctx: ToolContext): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
// fall back to the agent's final assistant message when the agent never
|
||||
// called report_progress (e.g. schedule/workflow_dispatch runs that have no
|
||||
// PR/issue context to comment on). lastProgressBody wins when present so we
|
||||
// don't double up the progress comment body in the job summary.
|
||||
async function writeJobSummary(toolState: ToolState, finalOutput?: string): Promise<void> {
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
const body = toolState.lastProgressBody || finalOutput;
|
||||
const summaryParts = [body, usageSummary].filter(Boolean);
|
||||
if (summaryParts.length > 0) {
|
||||
await writeSummary(summaryParts.join("\n\n"));
|
||||
}
|
||||
@@ -924,9 +925,7 @@ export async function main(): Promise<MainResult> {
|
||||
instructions,
|
||||
todoTracker,
|
||||
stopScript: runContext.repoSettings.stopScript,
|
||||
summaryFilePath: toolState.summaryFilePath,
|
||||
summarySeed: toolState.summarySeed,
|
||||
learningsFilePath: toolState.learningsFilePath,
|
||||
toolState,
|
||||
onActivityTimeout: onInnerActivityTimeout,
|
||||
onToolUse: (event) => {
|
||||
const wasTracked = recordDiffReadFromToolUse({
|
||||
@@ -1021,6 +1020,22 @@ export async function main(): Promise<MainResult> {
|
||||
await persistLearnings(toolContext);
|
||||
}
|
||||
|
||||
// when the agent harness returns success=false (e.g. unsubmitted-review
|
||||
// gate exhausted retries, stop-hook persistently failing), surface the
|
||||
// error in the progress comment so the user sees it instead of a
|
||||
// deleted-comment void. mirrors the catch-block error reporting for
|
||||
// thrown errors. runs before the stranded-comment cleanup below so
|
||||
// the comment is still around to update; reportErrorToComment sets
|
||||
// wasUpdated=true and the !result.success guard skips deletion.
|
||||
if (!result.success && toolContext && toolState.progressComment) {
|
||||
await reportErrorToComment({
|
||||
toolState,
|
||||
error: result.error || "agent run failed",
|
||||
}).catch((error) => {
|
||||
log.debug(`failure error report failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// clean up stranded progress comments. the comment is stale unless
|
||||
// report_progress wrote a final summary to it — three sub-cases all reduce
|
||||
// to !finalSummaryWritten:
|
||||
@@ -1034,14 +1049,34 @@ export async function main(): Promise<MainResult> {
|
||||
// so progressComment is already null by the time we get here for that path.
|
||||
// uses finalSummaryWritten (not todoTracker.enabled or wasUpdated) so cleanup
|
||||
// survives API failures in report_progress where cancel() ran but the write
|
||||
// didn't succeed, and isn't fooled by writes to *other* artifacts.
|
||||
if (toolContext && toolState.progressComment && !toolState.finalSummaryWritten) {
|
||||
// didn't succeed, and isn't fooled by writes to *other* artifacts. skipped
|
||||
// entirely on result.success===false: the error message just written above
|
||||
// is the user's only signal that the run happened — deleting it would
|
||||
// restore the same empty-void UX this commit fixes.
|
||||
if (
|
||||
toolContext &&
|
||||
result.success &&
|
||||
toolState.progressComment &&
|
||||
!toolState.finalSummaryWritten
|
||||
) {
|
||||
await deleteProgressComment(toolContext).catch((error) => {
|
||||
log.debug(`stranded progress comment cleanup failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
await writeJobSummary(toolState);
|
||||
// best-effort: failures writing the actions step summary must not throw
|
||||
// past this point. on the result.success===false branch above we already
|
||||
// wrote `result.error` to the progress comment, and a throw here would
|
||||
// jump to the outer catch which calls reportErrorToComment again with
|
||||
// the (less actionable) writeJobSummary error — silently overwriting the
|
||||
// gate's failure message in the progress comment. the step-summary write
|
||||
// is informational; let it fail silently rather than corrupt user-facing
|
||||
// output.
|
||||
try {
|
||||
await writeJobSummary(toolState, result.output);
|
||||
} catch (error) {
|
||||
log.debug(`job summary write failed: ${error}`);
|
||||
}
|
||||
|
||||
// emit structured output marker for test validation
|
||||
if (toolState.output) {
|
||||
|
||||
+88
-3
@@ -8,6 +8,7 @@ import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
||||
import { retry } from "../utils/retry.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { rejectIfLeadingDash } from "./git.ts";
|
||||
import { commentableLinesForFile } from "./review.ts";
|
||||
@@ -281,6 +282,12 @@ type CheckoutPrBranchParams = GitContext & {
|
||||
// legitimate git op that's holding the lock.
|
||||
const STALE_LOCK_AGE_MS = 30_000;
|
||||
|
||||
// PR head refs (refs/pull/N/head) sometimes lag the pull_request.opened
|
||||
// webhook by a few seconds. retry the missing-ref case with backoff
|
||||
// before giving up — see issue #591.
|
||||
const PULL_REF_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
|
||||
const PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
|
||||
|
||||
const GIT_LOCK_PATHS = [
|
||||
".git/shallow.lock",
|
||||
".git/index.lock",
|
||||
@@ -308,6 +315,62 @@ function cleanupStaleGitLocks(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns false when a PR's current state diverges from what we dispatched
|
||||
* on (closed/merged, or head SHA differs from pr.headSha). Used to short-
|
||||
* circuit the pull/N/head retry loop when the ref is missing because the
|
||||
* PR has moved on, not because of a webhook race.
|
||||
*
|
||||
* Network failures here are treated as "still valid" — we'd rather burn the
|
||||
* retry budget than wrongly abort on a transient API blip.
|
||||
*
|
||||
* Note: this answers "should we keep trying?", NOT "will the next fetch
|
||||
* succeed?". `pulls.get` (REST API) and `pull/N/head` (git ref) are served
|
||||
* by independent GitHub replicas with their own propagation lag, so
|
||||
* `pulls.get` reporting an open PR with a matching head SHA does not
|
||||
* guarantee the git ref is yet visible — and vice versa (see issue #591
|
||||
* for the original webhook-vs-ref replication-lag context).
|
||||
*/
|
||||
async function isPullRequestStillDispatchable(args: {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
pr: PrData;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { data } = await args.octokit.rest.pulls.get({
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
pull_number: args.pr.number,
|
||||
});
|
||||
if (data.state !== "open") return false;
|
||||
if (data.head.sha !== args.pr.headSha) return false;
|
||||
return true;
|
||||
} catch {
|
||||
// lenient — don't abort on API hiccups
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws the friendly clean-abort error when the PR has moved on since
|
||||
* dispatch. Wraps `isPullRequestStillDispatchable` so the abort message
|
||||
* lives in one place and is invoked from the inner `catch` around the
|
||||
* `pull/N/head` fetch on every missing-ref failure.
|
||||
*/
|
||||
async function abortIfPullRequestMoved(args: {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
pr: PrData;
|
||||
}): Promise<void> {
|
||||
const stillValid = await isPullRequestStillDispatchable(args);
|
||||
if (stillValid) return;
|
||||
throw new Error(
|
||||
`PR #${args.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout — re-trigger the run if this PR is still active.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper to checkout a PR branch and configure fork remotes.
|
||||
* Assumes origin remote is already configured with authentication.
|
||||
@@ -365,9 +428,31 @@ export async function checkoutPrBranch(
|
||||
|
||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
||||
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
});
|
||||
await retry(
|
||||
async () => {
|
||||
try {
|
||||
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
});
|
||||
} catch (e) {
|
||||
// on the webhook race, check whether the PR still matches what we
|
||||
// dispatched on. if it's been closed/merged or the head SHA moved,
|
||||
// no amount of retrying will populate the expected ref — surface a
|
||||
// clean abort error instead of burning the full retry budget.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (PULL_REF_MISSING_PATTERN.test(msg)) {
|
||||
await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
{
|
||||
delaysMs: PULL_REF_RETRY_DELAYS_MS,
|
||||
label: `pull/${pr.number}/head fetch`,
|
||||
shouldRetry: (e) =>
|
||||
PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
|
||||
}
|
||||
);
|
||||
|
||||
// checkout the branch
|
||||
$("git", ["checkout", localBranch], { log: false });
|
||||
|
||||
+69
-1
@@ -398,15 +398,75 @@ export const ReplyToReviewComment = type({
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* decision returned by `duplicateReplyDecision` when a session has already
|
||||
* posted an identical reply to the same parent review comment.
|
||||
*/
|
||||
export interface DuplicateReplyDecision {
|
||||
kind: "already-replied";
|
||||
commentId: number;
|
||||
url: string | undefined;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* decide whether a second reply_to_review_comment call in the same session
|
||||
* is a duplicate of an earlier reply to the same parent comment.
|
||||
*
|
||||
* the agent is instructed to call reply_to_review_comment exactly once per
|
||||
* parent comment per AddressReviews session, but in practice it sometimes
|
||||
* emits the same call twice. PR #610 reproduced this with Kimi K2:
|
||||
* identical body posted 3 seconds apart, only one tool_use event in the
|
||||
* agent log. the second post is always redundant and clutters the PR thread.
|
||||
*
|
||||
* we key on (comment_id, bodyWithFooter) so a legitimate follow-up reply
|
||||
* with different content still goes through. within a single run the
|
||||
* footer is constant (workflow run + model + jobId), so byte-equal bodies
|
||||
* catch the stutter without blocking real follow-ups.
|
||||
*
|
||||
* mirrors the shape of `duplicateReviewDecision` in mcp/review.ts.
|
||||
*/
|
||||
export function duplicateReplyDecision(params: {
|
||||
existing: { commentId: number; url: string | undefined; bodyWithFooter: string } | undefined;
|
||||
bodyWithFooter: string;
|
||||
}): DuplicateReplyDecision | null {
|
||||
const existing = params.existing;
|
||||
if (!existing) return null;
|
||||
if (existing.bodyWithFooter !== params.bodyWithFooter) return null;
|
||||
return {
|
||||
kind: "already-replied",
|
||||
commentId: existing.commentId,
|
||||
url: existing.url,
|
||||
reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "reply_to_review_comment",
|
||||
description:
|
||||
"Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).",
|
||||
"Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call exactly ONCE per parent comment you address in AddressReviews mode — duplicate calls with the same body are a no-op. Keep replies extremely brief (1 sentence max).",
|
||||
parameters: ReplyToReviewComment,
|
||||
execute: execute(async ({ pull_number, comment_id, body }) => {
|
||||
const bodyWithFooter = addFooter(ctx, body);
|
||||
|
||||
// guard against duplicate reply submissions in the same session.
|
||||
// see duplicateReplyDecision for the rationale.
|
||||
const dup = duplicateReplyDecision({
|
||||
existing: ctx.toolState.reviewReplies?.get(comment_id),
|
||||
bodyWithFooter,
|
||||
});
|
||||
if (dup) {
|
||||
log.info(`skipping duplicate review reply: ${dup.reason}`);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: dup.reason,
|
||||
commentId: dup.commentId,
|
||||
url: dup.url,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
@@ -420,6 +480,14 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
// a substantive write happened (used by reportErrorToComment / handleAgentResult)
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
// record this reply for in-session dedupe of subsequent identical calls.
|
||||
ctx.toolState.reviewReplies ??= new Map();
|
||||
ctx.toolState.reviewReplies.set(comment_id, {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
import { regex } from "arkregex";
|
||||
import { type } from "arktype";
|
||||
import type { StoredPushDest } from "../toolState.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { StoredPushDest, ToolContext } from "./server.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PushDestination = {
|
||||
|
||||
+7
-2
@@ -1,6 +1,7 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { formatMcpToolRef } from "../external.ts";
|
||||
import type { CommentableLines } from "../toolState.ts";
|
||||
import { getApiUrl } from "../utils/apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
@@ -16,6 +17,8 @@ import { deleteProgressComment } from "./comment.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export type { CommentableLines };
|
||||
|
||||
function getHttpStatus(err: unknown): number | undefined {
|
||||
if (typeof err !== "object" || err === null) return undefined;
|
||||
const status = (err as Record<string, unknown>).status;
|
||||
@@ -46,7 +49,6 @@ export function isTransientReviewError(err: unknown): boolean {
|
||||
export const TRANSIENT_REVIEW_RETRY_DELAYS_MS = [1_000, 3_000];
|
||||
|
||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
|
||||
|
||||
/**
|
||||
* parse a PR file's patch to determine which line numbers on each side are
|
||||
@@ -314,7 +316,7 @@ export const CreatePullRequestReview = type({
|
||||
.optional(),
|
||||
approved: type.boolean
|
||||
.describe(
|
||||
"Set to true to submit as an approval. ONLY when the review contains no actionable feedback — neither inline comments nor actionable content in the body. Defaults to false (comment-only review). Rejections are not supported."
|
||||
"Set to true to submit as an approval. Use for both 'no issues found' and informational `> [!NOTE]` reviews where the PR is mergeable as-is and nothing in the body warrants code changes — approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> [!IMPORTANT]` (recommended changes) and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
@@ -842,6 +844,9 @@ async function createAndSubmitWithFooter(
|
||||
// API_URL is misconfigured, and future footer-building changes could
|
||||
// introduce new throw paths. keep the whole body wrapped.
|
||||
try {
|
||||
// Fix buttons are suppressed on approving reviews — those are mergeable
|
||||
// by definition (either "no issues found" or `> [!NOTE]` informational
|
||||
// observations), so dispatching a fix run would be a UX trap.
|
||||
const customParts: string[] = [];
|
||||
if (!opts.approved) {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
+5
-4
@@ -24,13 +24,14 @@ function buildModeOverrides(t: (name: string) => string): Record<string, string>
|
||||
|
||||
An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
|
||||
|
||||
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
|
||||
2. Revise the plan based on the user's request:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
2. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
|
||||
3. Revise the plan based on the user's request:
|
||||
- incorporate the current plan (\`previousPlanBody\`) and the user's revision request
|
||||
- gather relevant codebase context (file paths, architecture notes from AGENTS.md)
|
||||
- produce a structured plan with clear milestones
|
||||
3. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
|
||||
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`,
|
||||
4. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
|
||||
5. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-140
@@ -3,23 +3,14 @@ import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import type { AgentUsage } from "../agents/index.ts";
|
||||
import { type AgentId, pullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { PrepResult } from "../prep/index.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { closeBrowserDaemon } from "../utils/browser.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import {
|
||||
type ProgressComment,
|
||||
type ProgressCommentType,
|
||||
parseProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
import type { AccountPlan } from "../utils/runContext.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
@@ -42,7 +33,6 @@ import { AddLabelsTool } from "./labels.ts";
|
||||
import { SetOutputTool } from "./output.ts";
|
||||
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import type { CommentableLines } from "./review.ts";
|
||||
import { CreatePullRequestReviewTool } from "./review.ts";
|
||||
import {
|
||||
GetReviewCommentsTool,
|
||||
@@ -54,135 +44,6 @@ import { addTools } from "./shared.ts";
|
||||
import { KillBackgroundTool, ShellTool } from "./shell.ts";
|
||||
import { UploadFileTool } from "./upload.ts";
|
||||
|
||||
export type BackgroundProcess = {
|
||||
pid: number;
|
||||
outputPath: string;
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
||||
checkoutSha?: string;
|
||||
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
||||
// review-time inline-comment validation matches the diff GitHub will anchor
|
||||
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
||||
// review would make listFiles (latest HEAD) disagree with the anchor,
|
||||
// silently dropping valid comments or letting invalid ones through.
|
||||
//
|
||||
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
||||
// the agent checks out PR B and then reviews PR A in the same session, the
|
||||
// cached snapshot for B would silently mis-validate A's comments — keying
|
||||
// by PR number forces a re-fetch when the target changes.
|
||||
//
|
||||
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
||||
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
||||
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
||||
// stale snapshot would silently mis-validate comments against the new SHA.
|
||||
// comparing both fields forces a re-fetch when either moves.
|
||||
commentableLinesByFile?: Map<string, CommentableLines>;
|
||||
commentableLinesPullNumber?: number;
|
||||
commentableLinesCheckoutSha?: string | undefined;
|
||||
// SHA to diff incrementally against — set from event payload on first checkout,
|
||||
// then from checkoutSha when review.ts detects new commits mid-review
|
||||
beforeSha?: string;
|
||||
selectedMode?: string;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
browserDaemon?: BrowserDaemon | undefined;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
reviewedSha: string | undefined;
|
||||
};
|
||||
dependencyInstallation?: {
|
||||
status: "not_started" | "in_progress" | "completed" | "failed";
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
||||
progressComment: ProgressComment | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
// set after a non-plan report_progress successfully writes the final summary.
|
||||
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
||||
finalSummaryWritten?: boolean;
|
||||
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||
existingPlanCommentId?: number;
|
||||
previousPlanBody?: string;
|
||||
// absolute path to the PR summary markdown file the agent edits in place.
|
||||
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
||||
// read back at end-of-run to persist to DB.
|
||||
summaryFilePath?: string;
|
||||
// exact bytes of the seeded snapshot file at run start. compared against
|
||||
// the file content at end-of-run to detect "agent never touched it" — in
|
||||
// that case persistSummary skips the DB write (saving the seed verbatim
|
||||
// would either re-write what the DB already has, on incremental runs, or
|
||||
// serialize the placeholder scaffold, on first runs).
|
||||
summarySeed?: string;
|
||||
// set to true after persistSummary completes once. prevents the error-path
|
||||
// call (which exists so a successful agent edit before a crash still gets
|
||||
// persisted) from redundantly re-running the DB PATCH on the
|
||||
// success-then-late-throw path.
|
||||
summaryPersistAttempted?: boolean;
|
||||
// absolute path to the rolling repo-level learnings markdown file the
|
||||
// agent reads at startup and may edit at end-of-run. seeded by main.ts
|
||||
// for every run from `Repo.learnings` (empty file when no learnings
|
||||
// exist yet); read back at end-of-run to persist any edits.
|
||||
learningsFilePath?: string;
|
||||
// exact bytes of the seeded learnings file at run start. compared
|
||||
// against the file content at end-of-run to detect "agent never touched
|
||||
// it" — in that case persistLearnings skips the DB PATCH (saving the
|
||||
// identical content would be a no-op write that wastes a LearningsRevision
|
||||
// row and the API round-trip).
|
||||
learningsSeed?: string;
|
||||
// mirror of `summaryPersistAttempted` for the learnings tmpfile — guards
|
||||
// the error-path / exit-signal callers from a redundant second PATCH
|
||||
// after the success path already persisted.
|
||||
learningsPersistAttempted?: boolean;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
diffCoverage?: DiffCoverageState | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const resolved = parseProgressComment(params.progressComment);
|
||||
|
||||
if (resolved) {
|
||||
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressComment: resolved,
|
||||
hadProgressComment: !!resolved,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
agentId: AgentId;
|
||||
repo: RunContextData["repo"];
|
||||
|
||||
@@ -74,18 +74,20 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **setup**: checkout or create the branch:
|
||||
2. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
|
||||
|
||||
3. **setup**: checkout or create the branch:
|
||||
- **PR event, modifying the existing PR**: call \`${t("checkout_pr")}\`
|
||||
- **new branch**: use \`${t("git")}\` to create a branch (\`git checkout -b pullfrog/branch-name\`)
|
||||
|
||||
3. **build**: implement changes using your native file and shell tools:
|
||||
4. **build**: implement changes using your native file and shell tools:
|
||||
- follow the plan (if you ran a plan phase)
|
||||
- plan your approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach.
|
||||
- run relevant tests/lints before committing
|
||||
|
||||
4. **self-review**: judgment call — does YOUR diff warrant a fresh-eyes pass?
|
||||
5. **self-review**: judgment call — does YOUR diff warrant a fresh-eyes pass?
|
||||
|
||||
Skip self-review (commit directly) when the diff is **genuinely trivial**:
|
||||
- doc typos, comment-only edits, whitespace/format-only, import reordering
|
||||
@@ -116,7 +118,7 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
|
||||
Review the findings, address valid points, and discard nitpicks or false positives. The reviewer is fallible — it biases toward *recommending additions* (defensive checks for impossible cases, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards). For each finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three is usually a signal to look harder for a fix that gets all three before settling for one that trades elegance for correctness. Reject bloat-shaped findings without applying them, and after applying the rest re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. The goal is code that is sound and correct *while remaining elegant*; the smallest diff that fixes the real defect almost always wins. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
|
||||
|
||||
5. **finalize**:
|
||||
6. **finalize**:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails — prepush errors are usually the repo's tests/lint, not infra timeouts)
|
||||
- create a PR via \`${t("create_pull_request")}\`
|
||||
- call \`${t("report_progress")}\` with the PR link or the exact error if push/PR failed
|
||||
@@ -131,23 +133,25 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Fetch review comments via \`${t("get_review_comments")}\`.
|
||||
2. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
|
||||
3. For each comment:
|
||||
3. Fetch review comments via \`${t("get_review_comments")}\`.
|
||||
|
||||
4. For each comment:
|
||||
- understand the feedback
|
||||
- evaluate whether applying it would leave the code more **sound, correct, AND elegant**. reviewers are fallible and bias toward *recommending additions* (defensive checks for impossible cases, extra abstractions, comments restating obvious code, tests asserting tautologies, "just-in-case" guards). if a request would add bloat — ceremony without commensurate correctness benefit — push back in your reply rather than mechanically applying it. two-out-of-three is usually a signal to look harder for a fix that gets all three before settling.
|
||||
- if the request stands, make the code change using your native tools; otherwise reply explaining why
|
||||
- record what was done (or why nothing was done)
|
||||
|
||||
4. Quality check:
|
||||
5. Quality check:
|
||||
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
|
||||
5. Finalize:
|
||||
6. Finalize:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
- reply to each comment using \`${t("reply_to_review_comment")}\`
|
||||
- reply to each comment **exactly once** using \`${t("reply_to_review_comment")}\` — do not re-emit the same call (the runtime dedupes identical bodies and the second call is wasted)
|
||||
- resolve addressed threads via \`${t("resolve_review_thread")}\`
|
||||
- call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)`,
|
||||
},
|
||||
@@ -169,11 +173,13 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **triage**: orient yourself on the PR — identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). orientation only — defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
|
||||
2. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
|
||||
|
||||
if the PR is **genuinely trivial**, skip steps 3–4 entirely and submit a \`No new issues found.\` review per step 5. there's no value in dispatching even one lens for a typo.
|
||||
3. **triage**: orient yourself on the PR — identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). orientation only — defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
|
||||
|
||||
if the PR is **genuinely trivial**, skip steps 4–5 entirely and submit a \`No new issues found.\` review per step 6. there's no value in dispatching even one lens for a typo.
|
||||
|
||||
"Genuinely trivial" (skip):
|
||||
- single-word doc typo, whitespace/format-only, comment-only across any number of files
|
||||
@@ -218,7 +224,7 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
- **holistic** — does the PR make sense as a whole? symmetric flows (delete for every create, rollback for every migration)?
|
||||
- **subsystem lenses** (invent as the PR demands) — auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling, etc.
|
||||
|
||||
3. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 3 entirely on a single subagent failure. each subagent gets:
|
||||
4. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 4 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff path / target — reading the diff and the codebase is its job
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
@@ -233,20 +239,33 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point — overlapping findings are a strong signal)
|
||||
|
||||
4. **aggregate & draft**: merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
|
||||
5. **aggregate & draft**: merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
|
||||
|
||||
for surviving findings, draft inline comments with NEW line numbers from the diff. every comment must be actionable, 2-3 sentences max. use GitHub permalink format for code references. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
|
||||
|
||||
5. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` — the review is the final record and the progress comment will be cleaned up automatically.
|
||||
6. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` — the review is the final record and the progress comment will be cleaned up automatically.
|
||||
|
||||
note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
|
||||
|
||||
The review body is structured as: \`[optional alert blockquote]\` → \`[PR summary using the default format below]\`. Inline comments are passed via the \`comments\` parameter, not in the body.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss):
|
||||
GitHub alert blockquotes render at four visual intensities — the callout is what the author sees first, so pick the one that matches what you want them to do:
|
||||
|
||||
- \`[!CAUTION]\` — large red banner. Reads as "this will break something."
|
||||
- \`[!IMPORTANT]\` — large purple banner. Reads as "you need to look at this before merging."
|
||||
- \`[!NOTE]\` — small blue inline callout. Reads as "FYI, here's something worth noting."
|
||||
- no callout — plain text. Reads as routine review output.
|
||||
|
||||
Two reinforcing levers: callout intensity (above) and \`approved\` (which gates the footer Fix-button affordance — Fix renders on every non-approving review, so \`approved: true\` suppresses it). Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing. Pick the tier the author's actual next action justifies.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss, broken core flows):
|
||||
\`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
|
||||
- **recommended changes** (non-critical):
|
||||
\`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> Consider ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
|
||||
- **must-address non-critical findings** (real consequences if shipped — incorrect behavior in non-critical paths, missing validation on user input, regressions the author should fix before merge):
|
||||
\`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> ...\`, followed by the PR summary. Reserve this tier for findings with concrete fallout — do NOT use \`[!IMPORTANT]\` for nits, style preferences, or "consider also" suggestions. Include all inline comments via \`comments\`.
|
||||
- **minor suggestions only** (single-line nits, doc/comment polish, defer-able observations, "rough edges"):
|
||||
\`approved: false\`. NO alert blockquote. Body opens directly with the PR summary. Include all inline comments via \`comments\`.
|
||||
- **informational observations** (mergeable as-is, nothing actionable — e.g. prior feedback addressed cleanly, surfacing a minor stale doc reference, calling out something noteworthy without recommending a change):
|
||||
\`approved: true\`. Body opens with \`> [!NOTE]\\n> ...\`, followed by the PR summary. Do NOT include inline \`comments\` — \`[!NOTE]\` signals "no action needed", which contradicts an actionable anchor; if a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead.
|
||||
- **no actionable issues**:
|
||||
\`approved: true\`. Body opens with \`No new issues found.\` followed by the PR summary.
|
||||
|
||||
@@ -255,7 +274,7 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
// IncrementalReview shares Review's multi-lens orchestrator pattern but
|
||||
// scopes the target to the incremental diff. The "issues must be NEW
|
||||
// since the last Pullfrog review" filter lives at aggregation time
|
||||
// (step 5), NOT in the subagent prompt — pushing the filter into
|
||||
// (step 6), NOT in the subagent prompt — pushing the filter into
|
||||
// subagents matches the canonical anneal anti-pattern of "list known
|
||||
// pre-existing failures — don't flag these" and suppresses signal on
|
||||
// regressions the new commits amplified. The review body is just
|
||||
@@ -269,15 +288,17 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
|
||||
2. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
|
||||
3. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 5 — anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
|
||||
3. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
|
||||
|
||||
4. **triage & fan out**: orient on the *incremental* changes — domain, seams, external contracts, user-facing surfaces.
|
||||
4. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 6 — anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
|
||||
|
||||
if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 7's non-substantive path (do NOT submit a review).
|
||||
5. **triage & fan out**: orient on the *incremental* changes — domain, seams, external contracts, user-facing surfaces.
|
||||
|
||||
if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 8's non-substantive path (do NOT submit a review).
|
||||
|
||||
"Genuinely trivial" (skip): formatting/comment tweaks, import reordering, lockfile regen, mechanical rename of import paths, whitespace-only.
|
||||
"Looks trivial but isn't" (do NOT skip — same anti-patterns as Review mode): 1-line changes to SQL/regex/auth/billing/permissions/signature-verification code; flipping feature-flag defaults or retry/timeout constants; money/tax/HTTP-method/redirect changes; tightening or loosening a comparison operator; mixed diffs with a semantic line buried in formatting.
|
||||
@@ -285,8 +306,8 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
|
||||
otherwise pick lenses by where the new commits concentrate risk — **there's no fixed count**, same calibration as Review mode (1 lens for pure refactor / isolated fix; 2–3 for typical features; 4–5 for high-stakes subsystem touches; 6+ is a smell). lens framing follows Review mode: themed lenses (correctness & invariants, impact when new commits remove/rename/deprecate things, research-validated assumptions, security, user-journey, operational readiness, integration & cross-cutting, test integrity, performance, holistic) and subsystem lenses (auth, billing, schema migration, etc.) — for high-stakes domains lead with the subsystem lens rather than the generic themed equivalent.
|
||||
|
||||
dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 4 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff scope (incremental diff path if available, full diff otherwise). do NOT tell them to skip pre-existing issues — that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 5), not in the subagent prompt
|
||||
dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 5 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff scope (incremental diff path if available, full diff otherwise). do NOT tell them to skip pre-existing issues — that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 6), not in the subagent prompt
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
- the read-only contract restated in your dispatch instructions so the rule is present twice (the subagent's system prompt also enforces it). The test: would this call still be a no-op if reverted? If not (PR comments, branch pushes, issue updates, set_output, label changes, dependency installs, etc.), don't make it.
|
||||
@@ -300,15 +321,21 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point)
|
||||
|
||||
5. **aggregate, draft, self-critique**: merge findings; de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 1 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 3) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable, 2-3 sentences max.
|
||||
6. **aggregate, draft, self-critique**: merge findings; de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 2 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable, 2-3 sentences max.
|
||||
|
||||
6. **build the review body** — a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one — when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
|
||||
7. **build the review body** — a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one — when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
|
||||
|
||||
7. Submit — Do NOT call \`report_progress\` or \`create_issue_comment\` — the review is the final record and the progress comment will be cleaned up automatically. Follow these rules:
|
||||
8. Submit — every run must end with EXACTLY ONE of \`${t("create_pull_request_review")}\` (substantive review) or \`${t("report_progress")}\` (no-review acknowledgement). do NOT call \`create_issue_comment\` for review output.
|
||||
|
||||
Same callout-intensity ladder as Review mode — \`[!CAUTION]\` (large red, "will break") → \`[!IMPORTANT]\` (large purple, "must address before merging") → \`[!NOTE]\` (small blue, "FYI") → no callout (plain text). And the same Fix-button lever: the footer renders a Fix button on every non-approving review, so \`approved: true\` suppresses it. Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing — pick the tier the author's actual next action justifies.
|
||||
|
||||
Follow these rules:
|
||||
- note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
|
||||
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit — the progress comment will be cleaned up automatically.
|
||||
- ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`), then the Reviewed-changes summary.
|
||||
- ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\` alert, then the Reviewed-changes summary.
|
||||
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Instead call \`${t("report_progress")}\` with a 1-2 sentence note explaining no review was warranted (e.g. "No new issues. Changes since last review are formatting-only."). this leaves a visible signal that the run completed.
|
||||
- ELSE IF NEW CRITICAL ISSUES (blocks merge — bugs, security, data loss, broken core flows): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, then the Reviewed-changes summary.
|
||||
- ELSE IF NEW MUST-ADDRESS NON-CRITICAL FINDINGS (real consequences if shipped — incorrect behavior, missing validation, regressions the author should fix before merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\`, then the Reviewed-changes summary. Do NOT use this tier for nits, style preferences, or "consider also" suggestions.
|
||||
- ELSE IF NEW MINOR SUGGESTIONS ONLY (single-line nits, doc/comment polish, defer-able observations, "rough edges"): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens directly with \`Reviewed the following changes:\\n\` (NO alert blockquote), then the Reviewed-changes summary.
|
||||
- ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing — e.g. prior feedback addressed cleanly with one minor stale doc reference, or a noteworthy positive observation): call \`${t("create_pull_request_review")}\` with \`approved: true\`, NO inline comments, and the review body. body opens with \`> [!NOTE]\\n> ...\` alert, then the Reviewed-changes summary. If a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead — \`[!NOTE]\` and inline comments don't mix.
|
||||
- ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("create_pull_request_review")}\` to create a PR review. If all previous reviews have been properly addressed and no new issues were discovered, you can set \`approved: true\`. body opens with \`No new issues. Reviewed the following changes:\\n\`, then the Reviewed-changes summary.`,
|
||||
},
|
||||
{
|
||||
@@ -317,13 +344,15 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Analyze the task and gather context:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Analyze the task and gather context:
|
||||
- read AGENTS.md and relevant codebase files
|
||||
- understand the architecture and constraints
|
||||
|
||||
2. Produce a structured, actionable plan with clear milestones.
|
||||
3. Produce a structured, actionable plan with clear milestones.
|
||||
|
||||
3. Call \`${t("report_progress")}\` with the plan.`,
|
||||
4. Call \`${t("report_progress")}\` with the plan.`,
|
||||
},
|
||||
{
|
||||
name: "Fix",
|
||||
@@ -331,20 +360,22 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
|
||||
2. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
|
||||
3. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||
3. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
|
||||
|
||||
4. Diagnose and fix:
|
||||
4. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||
|
||||
5. Diagnose and fix:
|
||||
- read the workflow file, reproduce locally with the EXACT same commands CI runs
|
||||
- fix the issue using your native file and shell tools
|
||||
- verify the fix by re-running the exact CI command
|
||||
- review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve without hesitation.
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
|
||||
5. Finalize:
|
||||
6. Finalize:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
- call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)`,
|
||||
},
|
||||
@@ -353,22 +384,24 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
description: "Resolve merge conflicts in a PR branch against the base branch",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **Setup**:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **Setup**:
|
||||
- Call \`${t("checkout_pr")}\` to get the PR branch.
|
||||
- Call \`${t("get_pull_request")}\` to identify the base branch (e.g., 'main').
|
||||
- Call \`${t("git_fetch")}\` to fetch the base branch.
|
||||
|
||||
2. **Merge Attempt**:
|
||||
3. **Merge Attempt**:
|
||||
- Run \`git merge origin/<base_branch>\` via shell.
|
||||
- If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed — **then stop; do not run steps 3–4.**
|
||||
- If it fails (conflicts), resolve them manually (continue to steps 3–4).
|
||||
- If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed — **then stop; do not run steps 4–5.**
|
||||
- If it fails (conflicts), resolve them manually (continue to steps 4–5).
|
||||
|
||||
3. **Resolve Conflicts**:
|
||||
4. **Resolve Conflicts**:
|
||||
- Run \`git status\` or parse the merge output to find the list of conflicting files.
|
||||
- For each conflicting file: read it, find the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`), understand the code context, and rewrite the file with the correct resolution. Remove all markers.
|
||||
- Verify the file syntax is correct after resolution.
|
||||
|
||||
4. **Finalize**:
|
||||
5. **Finalize**:
|
||||
- Run a final verification (build/test) to ensure the resolution works.
|
||||
- \`git add . && git commit -m "resolve merge conflicts"\`
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
@@ -380,15 +413,17 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"General-purpose tasks that don't fit other modes: answering questions, adding comments, labeling, running ad-hoc commands, or any direct request",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. For substantial work — code changes across multiple files, multi-step investigations:
|
||||
2. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
|
||||
|
||||
3. For substantial work — code changes across multiple files, multi-step investigations:
|
||||
- plan your approach before starting
|
||||
- use native file and shell tools for local operations
|
||||
- use ${pullfrogMcpName} MCP tools for GitHub/git operations
|
||||
- if code changes are needed: review your own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
|
||||
3. Finalize:
|
||||
4. Finalize:
|
||||
- if code changes were made, push to a pull request (new or existing) using \`${t("push_branch")}\` and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if push fails).
|
||||
- call \`${t("report_progress")}\` once with results — include exact tool errors if push or PR creation failed
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
|
||||
@@ -398,3 +433,18 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
|
||||
// static export for UI display — uses opencode format as the readable default
|
||||
export const modes: Mode[] = computeModes("opencode");
|
||||
|
||||
/**
|
||||
* modes that legitimately never modify the working tree. used by the post-run
|
||||
* dirty-tree gate to suppress the "commit and push" nudge — those modes
|
||||
* complete by submitting a review (`Review` / `IncrementalReview`) or by
|
||||
* posting a Plan comment (`Plan`), not by touching files. any leftover in the
|
||||
* tree at end-of-run is incidental tool noise (e.g. a `node_modules/` from a
|
||||
* stray install attempt) on an ephemeral worktree; nudging the agent to
|
||||
* commit it would produce a spurious PR.
|
||||
*/
|
||||
export const NON_COMMITTING_MODES: ReadonlySet<string> = new Set([
|
||||
"Review",
|
||||
"IncrementalReview",
|
||||
"Plan",
|
||||
]);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pullfrog",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pullfrog": "dist/cli.mjs",
|
||||
|
||||
@@ -135,14 +135,21 @@ export const installNodeDependencies: PrepDefinition = {
|
||||
}
|
||||
}
|
||||
|
||||
// get the frozen install command (or fallback to regular install)
|
||||
const resolved = resolveCommand(agent, "frozen", []) || resolveCommand(agent, "install", []);
|
||||
// frozen-lockfile install only. eager prep is non-mutating by contract:
|
||||
// we run it before the agent starts and any artifact it leaves in the
|
||||
// tree (e.g. a generated `package-lock.json`) trips the dirty-tree
|
||||
// post-run gate and produces a spurious PR. `frozen` commands
|
||||
// (`npm ci`, `pnpm install --frozen-lockfile`, etc.) fail cleanly
|
||||
// without modifying state when there's no lockfile, which is exactly
|
||||
// what we want — repos that need a non-frozen install must opt in via
|
||||
// a `setup` lifecycle hook (`action/utils/lifecycle.ts`).
|
||||
const resolved = resolveCommand(agent, "frozen", []);
|
||||
if (!resolved) {
|
||||
return {
|
||||
language: "node",
|
||||
packageManager,
|
||||
dependenciesInstalled: false,
|
||||
issues: [`no install command found for ${agent}`],
|
||||
issues: [`no frozen-install command available for ${agent}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+70
-36
@@ -1,61 +1,95 @@
|
||||
/**
|
||||
* emits a JSON array of { slug, agent, name } entries for the `models-live`
|
||||
* matrix job. `agent` is auto-derived from the alias provider and matches the
|
||||
* harness the runtime would pick in production.
|
||||
* emits a JSON array of { slug, agent, name } entries for one of two CI matrix
|
||||
* jobs. `agent` mirrors the harness the runtime would pick in production
|
||||
* (anthropic/* → claude-code, everything else → opencode).
|
||||
*
|
||||
* set MATRIX_FILTER to a substring to restrict the matrix to matching aliases
|
||||
* — useful for iterating on a single provider without paying for every model.
|
||||
* MODE=aliases (default) — every alias minus pruned passthroughs. consumed by
|
||||
* `models-live`, which runs the cheap top-level CLI smoke per alias
|
||||
* (`action/test/model-smoke.ts`) to validate resolution + auth.
|
||||
*
|
||||
* passthrough pruning: openrouter/* aliases and keyed opencode/* aliases are
|
||||
* just routing-layer wrappers around models we already smoke-test directly
|
||||
* (anthropic/*, openai/*, google/*, etc). running every passthrough burns CI
|
||||
* minutes without catching anything the direct smoke doesn't. we keep one
|
||||
* canary per routing layer to validate the routing layer itself is alive;
|
||||
* slug-drift is caught separately by the `models-catalog` job. set
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 to bypass this for full validation.
|
||||
* MODE=flagships — one standard-tier model per provider. consumed by
|
||||
* `providers-live`, which runs the full harness smoke
|
||||
* (`pnpm runtest smoke <agent>`) to validate provider-class tool-calling
|
||||
* (e.g. Gemini schema sanitizer, OpenAI tool-call format).
|
||||
*
|
||||
* passthrough pruning (aliases mode): openrouter/* aliases and keyed opencode/*
|
||||
* aliases are routing-layer wrappers around models we already smoke-test
|
||||
* directly. running every passthrough burns CI minutes without catching
|
||||
* anything new — slug-drift is covered by the `models-catalog` job. one canary
|
||||
* per routing layer proves the routing surface (auth, tool-call translation)
|
||||
* is alive; set INCLUDE_PASSTHROUGHS=1 to bypass for full validation.
|
||||
*
|
||||
* usage:
|
||||
* node action/test/list-aliases.ts
|
||||
* MODE=flagships node action/test/list-aliases.ts
|
||||
* MATRIX_FILTER=gemini node action/test/list-aliases.ts
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 node action/test/list-aliases.ts
|
||||
* INCLUDE_EXPENSIVE=1 node action/test/list-aliases.ts
|
||||
* INCLUDE_PASSTHROUGHS=1 node action/test/list-aliases.ts
|
||||
*/
|
||||
import { modelAliases } from "../models.ts";
|
||||
|
||||
function agentForSlug(slug: string): "claude" | "opencode" {
|
||||
return slug.startsWith("anthropic/") ? "claude" : "opencode";
|
||||
}
|
||||
|
||||
// one canary per routing layer — proves the routing surface (auth, tool-call
|
||||
// translation) is alive without re-testing every underlying model.
|
||||
const ROUTING_CANARIES = new Set(["openrouter/claude-sonnet", "opencode/claude-sonnet"]);
|
||||
|
||||
// pruned by default; opt back in with INCLUDE_EXPENSIVE=1 or MATRIX_FILTER.
|
||||
// gpt-5.5-pro burns ~$2.40/run on this fixture — too expensive per-push.
|
||||
const EXPENSIVE_ALIASES = new Set(["openai/gpt-pro"]);
|
||||
// hand-picked "standard good model" per provider — not the pro/opus tier (too
|
||||
// expensive for per-push) and not the free/experimental tier (too flaky). these
|
||||
// aliases anchor the harness smoke job that catches provider-class regressions
|
||||
// like Gemini schema sanitization or OpenAI tool-call format drift. the
|
||||
// assertion below catches slug-drift loudly, but adding a NEW provider without
|
||||
// an entry here silently omits it from `providers-live` — see
|
||||
// wiki/models-catalog.md "To add a provider".
|
||||
const FLAGSHIPS = [
|
||||
"anthropic/claude-sonnet",
|
||||
"openai/gpt",
|
||||
"google/gemini-pro",
|
||||
"xai/grok",
|
||||
"deepseek/deepseek-pro",
|
||||
"moonshotai/kimi-k2",
|
||||
"opencode/big-pickle",
|
||||
"openrouter/claude-sonnet",
|
||||
];
|
||||
|
||||
function isPrunablePassthrough(alias: (typeof modelAliases)[number]): boolean {
|
||||
if (ROUTING_CANARIES.has(alias.slug)) return false;
|
||||
if (alias.provider === "openrouter") return true;
|
||||
// opencode FREE models (big-pickle, mimo, minimax, gpt-5-nano) are unique
|
||||
// to opencode and used in prod — keep them. only prune the keyed mirrors.
|
||||
if (alias.provider === "opencode" && !alias.isFree) return true;
|
||||
return false;
|
||||
return alias.provider === "opencode" && !alias.isFree;
|
||||
}
|
||||
|
||||
const filter = process.env.MATRIX_FILTER?.trim() ?? "";
|
||||
const includeAllPassthroughs = process.env.INCLUDE_ALL_PASSTHROUGHS === "1";
|
||||
const includeExpensive = process.env.INCLUDE_EXPENSIVE === "1" || filter !== "";
|
||||
|
||||
const matrix = modelAliases
|
||||
.filter((alias) => (filter ? alias.slug.toLowerCase().includes(filter.toLowerCase()) : true))
|
||||
.filter((alias) => includeAllPassthroughs || !isPrunablePassthrough(alias))
|
||||
.filter((alias) => includeExpensive || !EXPENSIVE_ALIASES.has(alias.slug))
|
||||
.map((alias) => ({
|
||||
function toMatrixEntry(alias: (typeof modelAliases)[number]) {
|
||||
return {
|
||||
slug: alias.slug,
|
||||
agent: agentForSlug(alias.slug),
|
||||
agent: alias.slug.startsWith("anthropic/") ? "claude" : "opencode",
|
||||
// readable display name (GHA renders slashes awkwardly in matrix job titles)
|
||||
name: alias.slug.replace("/", "-"),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
const mode = process.env.MODE === "flagships" ? "flagships" : "aliases";
|
||||
const filter = process.env.MATRIX_FILTER?.trim().toLowerCase() ?? "";
|
||||
const includePassthroughs = process.env.INCLUDE_PASSTHROUGHS === "1";
|
||||
|
||||
const aliasBySlug = new Map(modelAliases.map((a) => [a.slug, a]));
|
||||
const matrix = (() => {
|
||||
if (mode === "flagships") {
|
||||
return FLAGSHIPS.map((slug) => {
|
||||
const alias = aliasBySlug.get(slug);
|
||||
if (!alias) {
|
||||
throw new Error(
|
||||
`list-aliases: flagship "${slug}" missing from modelAliases — update FLAGSHIPS`
|
||||
);
|
||||
}
|
||||
return alias;
|
||||
})
|
||||
.filter((alias) => !filter || alias.slug.toLowerCase().includes(filter))
|
||||
.map(toMatrixEntry);
|
||||
}
|
||||
return modelAliases
|
||||
.filter((alias) => {
|
||||
if (filter && !alias.slug.toLowerCase().includes(filter)) return false;
|
||||
if (!includePassthroughs && isPrunablePassthrough(alias)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(toMatrixEntry);
|
||||
})();
|
||||
|
||||
process.stdout.write(JSON.stringify(matrix));
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* model-smoke: per-alias resolution + auth check that bypasses the Pullfrog
|
||||
* harness. resolves a model alias to its concrete provider/model + agent CLI,
|
||||
* invokes the CLI directly with a trivial "reply OK" prompt, and asserts the
|
||||
* provider replied. validates exactly the surface that changes when models.ts
|
||||
* changes — alias → resolve mapping, agent classification, env-var wiring —
|
||||
* without booting Docker, MCP, or the full agent runtime.
|
||||
*
|
||||
* tool-calling correctness is a property of the underlying model, not the
|
||||
* alias; the `providers-live` job runs the full harness smoke once per
|
||||
* provider (one standard-tier model each), which is enough.
|
||||
*
|
||||
* usage:
|
||||
* node action/test/model-smoke.ts --slug openai/gpt
|
||||
* PULLFROG_MODEL=openai/gpt node action/test/model-smoke.ts
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "dotenv";
|
||||
import { modelAliases, resolveCliModel } from "../models.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
|
||||
config({ path: join(import.meta.dirname, "..", ".env") });
|
||||
config({ path: join(import.meta.dirname, "..", "..", ".env") });
|
||||
|
||||
const PROMPT = "Reply with exactly OK and nothing else.";
|
||||
const MATCH = /\bOK\b/i;
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
function parseSlug(): string {
|
||||
const argIdx = process.argv.indexOf("--slug");
|
||||
if (argIdx >= 0 && process.argv[argIdx + 1]) return process.argv[argIdx + 1];
|
||||
if (process.env.PULLFROG_MODEL) return process.env.PULLFROG_MODEL;
|
||||
throw new Error("model-smoke: pass --slug <alias> or set PULLFROG_MODEL");
|
||||
}
|
||||
|
||||
type Plan =
|
||||
| { agent: "opencode"; cliPath: string; args: string[] }
|
||||
| { agent: "claude"; cliPath: string; args: string[] };
|
||||
|
||||
async function plan(slug: string): Promise<Plan> {
|
||||
const alias = modelAliases.find((a) => a.slug === slug);
|
||||
if (!alias) throw new Error(`model-smoke: unknown alias "${slug}"`);
|
||||
|
||||
// walk the fallback chain so deprecated aliases (those with `fallback` set,
|
||||
// e.g. opencode/mimo-v2-pro-free → opencode/big-pickle) hit their replacement
|
||||
// instead of the dead resolve target. mirrors production via resolveCliModel.
|
||||
const cliModel = resolveCliModel(slug);
|
||||
if (!cliModel) throw new Error(`model-smoke: fallback chain for "${slug}" is broken or cyclic`);
|
||||
|
||||
// anthropic/* aliases run through claude-code in production; everything else
|
||||
// (openai, google, xai, deepseek, moonshot, opencode, openrouter) runs through
|
||||
// opencode. mirrors the inline classification in list-aliases.ts toMatrixEntry().
|
||||
if (slug.startsWith("anthropic/")) {
|
||||
const cliPath = await installFromNpmTarball({
|
||||
packageName: "@anthropic-ai/claude-code",
|
||||
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
|
||||
executablePath: "cli.js",
|
||||
installDependencies: false,
|
||||
});
|
||||
// claude expects a bare model id (e.g. "claude-sonnet-4-6"), not "anthropic/claude-sonnet-4-6"
|
||||
const bareModel = cliModel.split("/").slice(1).join("/");
|
||||
return {
|
||||
agent: "claude",
|
||||
cliPath,
|
||||
args: [cliPath, "-p", PROMPT, "--model", bareModel],
|
||||
};
|
||||
}
|
||||
|
||||
const cliPath = await installFromNpmTarball({
|
||||
packageName: "opencode-ai",
|
||||
version: getDevDependencyVersion("opencode-ai"),
|
||||
executablePath: "bin/opencode",
|
||||
installDependencies: true,
|
||||
});
|
||||
return {
|
||||
agent: "opencode",
|
||||
cliPath,
|
||||
args: ["run", "--model", cliModel, PROMPT],
|
||||
};
|
||||
}
|
||||
|
||||
type SpawnResult = { ok: boolean; output: string; reason: string };
|
||||
|
||||
function runCli(p: Plan, env: NodeJS.ProcessEnv): Promise<SpawnResult> {
|
||||
// claude's cli.js shebangs to env node, but we invoke node explicitly to
|
||||
// avoid PATH-resolution surprises in CI runners; opencode is a real binary.
|
||||
const command = p.agent === "claude" ? "node" : p.cliPath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, p.args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
const output = stdout + (stderr ? `\n---stderr---\n${stderr}` : "");
|
||||
if (signal === "SIGKILL") {
|
||||
resolve({ ok: false, output, reason: `timed out after ${TIMEOUT_MS / 1000}s` });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve({ ok: false, output, reason: `exit ${code}` });
|
||||
return;
|
||||
}
|
||||
if (!MATCH.test(stdout)) {
|
||||
resolve({ ok: false, output, reason: "no OK in stdout" });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, output, reason: "ok" });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, output: stderr, reason: `spawn error: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const slug = parseSlug();
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "model-smoke-"));
|
||||
const homeDir = join(tempDir, "home");
|
||||
|
||||
// installFromNpmTarball reads PULLFROG_TEMP_DIR from process.env, not from
|
||||
// the spawn env, so we mutate process.env up-front. HOME/XDG_CONFIG_HOME are
|
||||
// redirected to keep the agent CLIs from picking up the dev user's config.
|
||||
process.env.PULLFROG_TEMP_DIR = tempDir;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.XDG_CONFIG_HOME = join(homeDir, ".config");
|
||||
// opencode reads GOOGLE_GENERATIVE_AI_API_KEY for gemini; mirror the harness fallback.
|
||||
if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY && process.env.GEMINI_API_KEY) {
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
}
|
||||
|
||||
console.log(`» model-smoke ${slug}`);
|
||||
const p = await plan(slug);
|
||||
console.log(
|
||||
`» agent=${p.agent} cmd=${[p.agent === "claude" ? "node" : p.cliPath, ...p.args].join(" ")}`
|
||||
);
|
||||
|
||||
const result = await runCli(p, process.env);
|
||||
if (result.ok) {
|
||||
console.log(`✓ ${slug} (${p.agent})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`✗ ${slug} (${p.agent}): ${result.reason}`);
|
||||
if (result.output) console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import type { AgentUsage } from "./agents/shared.ts";
|
||||
import type { PrepResult } from "./prep/types.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import type { DiffCoverageState } from "./utils/diffCoverage.ts";
|
||||
import {
|
||||
type ProgressComment,
|
||||
type ProgressCommentType,
|
||||
parseProgressComment,
|
||||
} from "./utils/progressComment.ts";
|
||||
import type { TodoTracker } from "./utils/todoTracking.ts";
|
||||
|
||||
export type BackgroundProcess = {
|
||||
pid: number;
|
||||
outputPath: string;
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid inline-comment anchor lines per side at a particular checkout SHA.
|
||||
* Lives here (not in `mcp/review.ts`) so `ToolState` — which caches
|
||||
* `Map<path, CommentableLines>` per checkout — does not pull the MCP server
|
||||
* graph into every consumer of run state (the action's main loop, agent
|
||||
* harnesses, cf-worker indexing).
|
||||
*/
|
||||
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
|
||||
|
||||
/**
|
||||
* mutable per-run record of facts that occurred during execution. shared
|
||||
* between the action process and the MCP server (one process — toolState is
|
||||
* just a JS object passed by reference into both surfaces).
|
||||
*
|
||||
* design rule: ToolState is LITERAL. each field records a thing that
|
||||
* happened — `review` is set when `create_pull_request_review` succeeded,
|
||||
* `finalSummaryWritten` flips when `report_progress` wrote a non-plan body,
|
||||
* `selectedMode` is set when `select_mode` was called. fields should never
|
||||
* encode the absence of an event ("unsubmittedReview", "missingArtifact"),
|
||||
* speculative state, or values derived from other fields.
|
||||
*
|
||||
* any predicate the rest of the code needs ("the agent picked review mode but
|
||||
* never produced a review or progress write") is computed inline at the call
|
||||
* site, not stored. derived state in this struct invariably drifts from the
|
||||
* literal fields under refactors and is the wrong layer for the check.
|
||||
*
|
||||
* write narrowly: prefer adding state inside the tool that mutates it (e.g.
|
||||
* `create_pull_request_review` populates `toolState.review`) and reading
|
||||
* narrowly elsewhere. don't introduce flags from main.ts that mirror what an
|
||||
* MCP tool already records.
|
||||
*/
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
||||
checkoutSha?: string;
|
||||
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
||||
// review-time inline-comment validation matches the diff GitHub will anchor
|
||||
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
||||
// review would make listFiles (latest HEAD) disagree with the anchor,
|
||||
// silently dropping valid comments or letting invalid ones through.
|
||||
//
|
||||
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
||||
// the agent checks out PR B and then reviews PR A in the same session, the
|
||||
// cached snapshot for B would silently mis-validate A's comments — keying
|
||||
// by PR number forces a re-fetch when the target changes.
|
||||
//
|
||||
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
||||
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
||||
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
||||
// stale snapshot would silently mis-validate comments against the new SHA.
|
||||
// comparing both fields forces a re-fetch when either moves.
|
||||
commentableLinesByFile?: Map<string, CommentableLines>;
|
||||
commentableLinesPullNumber?: number;
|
||||
commentableLinesCheckoutSha?: string | undefined;
|
||||
// SHA to diff incrementally against — set from event payload on first checkout,
|
||||
// then from checkoutSha when review.ts detects new commits mid-review
|
||||
beforeSha?: string;
|
||||
selectedMode?: string;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
browserDaemon?: BrowserDaemon | undefined;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
reviewedSha: string | undefined;
|
||||
};
|
||||
// dedupe key: parent review comment_id → most-recent reply written this
|
||||
// session by reply_to_review_comment. used by duplicateReplyDecision to
|
||||
// skip identical-body re-emissions of the same call (PR #610 root cause).
|
||||
// body-keyed (not just id-keyed) so legitimate follow-up replies with
|
||||
// different content still go through.
|
||||
reviewReplies?: Map<
|
||||
number,
|
||||
{ commentId: number; url: string | undefined; bodyWithFooter: string }
|
||||
>;
|
||||
dependencyInstallation?: {
|
||||
status: "not_started" | "in_progress" | "completed" | "failed";
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
||||
progressComment: ProgressComment | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
// set after a non-plan report_progress successfully writes the final summary.
|
||||
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
||||
finalSummaryWritten?: boolean;
|
||||
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||
existingPlanCommentId?: number;
|
||||
previousPlanBody?: string;
|
||||
// absolute path to the PR summary markdown file the agent edits in place.
|
||||
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
||||
// read back at end-of-run to persist to DB.
|
||||
summaryFilePath?: string;
|
||||
// exact bytes of the seeded snapshot file at run start. compared against
|
||||
// the file content at end-of-run to detect "agent never touched it" — in
|
||||
// that case persistSummary skips the DB write (saving the seed verbatim
|
||||
// would either re-write what the DB already has, on incremental runs, or
|
||||
// serialize the placeholder scaffold, on first runs).
|
||||
summarySeed?: string;
|
||||
// set to true after persistSummary completes once. prevents the error-path
|
||||
// call (which exists so a successful agent edit before a crash still gets
|
||||
// persisted) from redundantly re-running the DB PATCH on the
|
||||
// success-then-late-throw path.
|
||||
summaryPersistAttempted?: boolean;
|
||||
// absolute path to the rolling repo-level learnings markdown file the
|
||||
// agent reads at startup and may edit at end-of-run. seeded by main.ts
|
||||
// for every run from `Repo.learnings` (empty file when no learnings
|
||||
// exist yet); read back at end-of-run to persist any edits.
|
||||
learningsFilePath?: string;
|
||||
// exact bytes of the seeded learnings file at run start. compared
|
||||
// against the file content at end-of-run to detect "agent never touched
|
||||
// it" — in that case persistLearnings skips the DB PATCH (saving the
|
||||
// identical content would be a no-op write that wastes a LearningsRevision
|
||||
// row and the API round-trip).
|
||||
learningsSeed?: string;
|
||||
// mirror of `summaryPersistAttempted` for the learnings tmpfile — guards
|
||||
// the error-path / exit-signal callers from a redundant second PATCH
|
||||
// after the success path already persisted.
|
||||
learningsPersistAttempted?: boolean;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
diffCoverage?: DiffCoverageState | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const resolved = parseProgressComment(params.progressComment);
|
||||
|
||||
if (resolved) {
|
||||
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressComment: resolved,
|
||||
hadProgressComment: !!resolved,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { filterEnv } from "./secrets.ts";
|
||||
import { getDevDependencyVersion } from "./version.ts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
|
||||
+4
-1
@@ -221,8 +221,11 @@ const checkRepositoryAccess = async (
|
||||
headers: { Authorization: `token ${token}` },
|
||||
});
|
||||
|
||||
const ownerLower = repoOwner.toLowerCase();
|
||||
const nameLower = repoName.toLowerCase();
|
||||
return response.repositories.some(
|
||||
(repo) => repo.owner.login === repoOwner && repo.name === repoName
|
||||
(repo) =>
|
||||
repo.owner.login.toLowerCase() === ownerLower && repo.name.toLowerCase() === nameLower
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { AgentResult } from "../agents/shared.ts";
|
||||
import type { MainResult } from "../main.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { reportErrorToComment } from "./errorReport.ts";
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ShellPermission } from "../external.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { OctokitWithPlugins } from "./github.ts";
|
||||
import { isInsideDocker } from "./globals.ts";
|
||||
|
||||
Reference in New Issue
Block a user